├── .gitignore ├── COPYING ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── examples ├── cisco_bgp_summary_example ├── cisco_bgp_summary_template ├── cisco_ipv6_interface_example ├── cisco_ipv6_interface_template ├── cisco_version_example ├── cisco_version_template ├── f10_ip_bgp_summary_example ├── f10_ip_bgp_summary_template ├── f10_version_example ├── f10_version_template ├── index ├── juniper_bgp_summary_example ├── juniper_bgp_summary_template ├── juniper_version_example ├── juniper_version_template ├── unix_ifcfg_example └── unix_ifcfg_template ├── setup.cfg ├── setup.py ├── testdata ├── clitable_templateA ├── clitable_templateB ├── clitable_templateC ├── clitable_templateD ├── default_index ├── nondefault_index ├── parseindex_index ├── parseindexfail1_index ├── parseindexfail2_index └── parseindexfail3_index ├── tests ├── __init__.py ├── clitable_test.py ├── terminal_test.py ├── textfsm_test.py └── texttable_test.py └── textfsm ├── __init__.py ├── clitable.py ├── parser.py ├── terminal.py └── texttable.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | 3 | /dist/ 4 | /build/ 5 | 6 | /*.egg-info 7 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include COPYING 2 | include *.md 3 | include *.txt 4 | recursive-include tests *.py 5 | recursive-include examples * 6 | recursive-include testdata * 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | TextFSM 2 | ======= 3 | 4 | Python module which implements a template based state machine for parsing 5 | semi-formatted text. Originally developed to allow programmatic access to 6 | information returned from the command line interface (CLI) of networking 7 | devices. 8 | 9 | The engine takes two inputs - a template file, and text input (such as command 10 | responses from the CLI of a device) and returns a list of records that contains 11 | the data parsed from the text. 12 | 13 | A template file is needed for each uniquely structured text input. Some examples 14 | are provided with the code and users are encouraged to develop their own. 15 | 16 | By developing a pool of template files, scripts can call TextFSM to parse useful 17 | information from a variety of sources. It is also possible to use different 18 | templates on the same data in order to create different tables (or views). 19 | 20 | TextFSM was developed internally at Google and released under the Apache 2.0 21 | licence for the benefit of the wider community. 22 | 23 | [**See documentation for more details.**](https://github.com/google/textfsm/wiki/TextFSM) 24 | 25 | Before contributing 26 | ------------------- 27 | If you are not a Google employee, our lawyers insist that you sign a Contributor 28 | Licence Agreement (CLA). 29 | 30 | If you are an individual writing original source code and you're sure you own 31 | the intellectual property, then you'll need to sign an 32 | [individual CLA](https://cla.developers.google.com/about/google-individual). 33 | Individual CLAs can be signed electronically. If you work for a company that 34 | wants to allow you to contribute your work, then you'll need to sign a 35 | [corporate CLA](https://cla.developers.google.com/clas). 36 | The Google CLA is based on Apache's. Note that unlike some projects 37 | (notably GNU projects), we do not require a transfer of copyright. You still own 38 | the patch. 39 | 40 | Sadly, even the smallest patch needs a CLA. 41 | -------------------------------------------------------------------------------- /examples/cisco_bgp_summary_example: -------------------------------------------------------------------------------- 1 | BGP router identifier 192.0.2.70, local AS number 65550 2 | BGP table version is 9, main routing table version 9 3 | 4 network entries using 468 bytes of memory 4 | 4 path entries using 208 bytes of memory 5 | 3/2 BGP path/bestpath attribute entries using 420 bytes of memory 6 | 1 BGP AS-PATH entries using 24 bytes of memory 7 | 1 BGP community entries using 24 bytes of memory 8 | 0 BGP route-map cache entries using 0 bytes of memory 9 | 0 BGP filter-list cache entries using 0 bytes of memory 10 | BGP using 1144 total bytes of memory 11 | BGP activity 12/4 prefixes, 12/4 paths, scan interval 5 secs 12 | 13 | Neighbor V AS MsgRcvd MsgSent TblVer InQ OutQ Up/Down State/PfxRcd 14 | 192.0.2.77 4 65551 6965 1766 9 0 0 5w4d 1 15 | 192.0.2.78 4 65552 6965 1766 9 0 0 5w4d 10 16 | -------------------------------------------------------------------------------- /examples/cisco_bgp_summary_template: -------------------------------------------------------------------------------- 1 | # Carry down the local end information so that it is present on each row item. 2 | Value Filldown RouterID (\S+) 3 | Value Filldown LocalAS (\d+) 4 | Value RemoteAS (\d+) 5 | Value Required RemoteIP (\d+(\.\d+){3}) 6 | Value Uptime (\d+\S+) 7 | Value Received_V4 (\d+) 8 | Value Status (\D.*) 9 | 10 | Start 11 | ^BGP router identifier ${RouterID}, local AS number ${LocalAS} 12 | ^${RemoteIP}\s+\d+\s+${RemoteAS}(\s+\S+){5}\s+${Uptime}\s+${Received_V4} -> Record 13 | ^${RemoteIP}\s+\d+\s+${RemoteAS}(\s+\S+){5}\s+${Uptime}\s+${Status} -> Record 14 | 15 | # Last record is already recorded then skip doing so here. 16 | EOF 17 | -------------------------------------------------------------------------------- /examples/cisco_ipv6_interface_example: -------------------------------------------------------------------------------- 1 | Dialer0 is up, line protocol is up 2 | IPv6 is enabled, link-local address is FE80::21B:2BFF:FECE:4EE3 3 | No Virtual link-local address(es): 4 | Description: PPP Dialer 5 | Stateless address autoconfig enabled 6 | General-prefix in use for addressing 7 | Global unicast address(es): 8 | 2001:4567:1212:B2:21B:2BFF:FECE:4EE3, subnet is 2001:4567:1212:B2::/64 [EUI/CAL/PRE] 9 | valid lifetime 5041 preferred lifetime 5041 10 | 2001:4567:1111:56FF::1, subnet is 2001:4567:1111:56FF::1/128 [CAL/PRE] 11 | valid lifetime 5945 preferred lifetime 2344 12 | Joined group address(es): 13 | FF02::1 14 | FF02::2 15 | FF02::1:FF00:1 16 | FF02::1:FFCE:4EE3 17 | MTU is 1500 bytes 18 | ICMP error messages limited to one every 100 milliseconds 19 | ICMP redirects are enabled 20 | ICMP unreachables are sent 21 | Input features: Access List 22 | Inbound access list IPV6-IN 23 | ND DAD is enabled, number of DAD attempts: 1 24 | ND reachable time is 30000 milliseconds (using 21397) 25 | Hosts use stateless autoconfig for addresses. 26 | Vlan1 is up, line protocol is up 27 | IPv6 is enabled, link-local address is FE80::21B:2BFF:FECE:4EE3 28 | No Virtual link-local address(es): 29 | Description: Local VLAN 30 | General-prefix in use for addressing 31 | Global unicast address(es): 32 | 2001:4567:1212:5600::1, subnet is 2001:4567:1212:5600::/64 [CAL/PRE] 33 | valid lifetime 5943 preferred lifetime 2342 34 | Joined group address(es): 35 | FF02::1 36 | FF02::2 37 | FF02::1:2 38 | FF02::1:FF00:1 39 | FF02::1:FFCE:4EE3 40 | FF05::1:3 41 | MTU is 1500 bytes 42 | ICMP error messages limited to one every 100 milliseconds 43 | ICMP redirects are enabled 44 | ICMP unreachables are sent 45 | ND DAD is enabled, number of DAD attempts: 1 46 | ND reachable time is 30000 milliseconds (using 26371) 47 | ND advertised reachable time is 0 (unspecified) 48 | ND advertised retransmit interval is 0 (unspecified) 49 | ND router advertisements are sent every 200 seconds 50 | ND router advertisements live for 1800 seconds 51 | ND advertised default router preference is Medium 52 | Hosts use stateless autoconfig for addresses. 53 | Hosts use DHCP to obtain other configuration. 54 | -------------------------------------------------------------------------------- /examples/cisco_ipv6_interface_template: -------------------------------------------------------------------------------- 1 | Value Interface (\S+) 2 | Value Admin (\S+) 3 | Value Oper (\S+) 4 | Value Description (.*) 5 | Value LinkLocal (\S+) 6 | Value List Addresses (\S+) 7 | Value List Subnets (\S+) 8 | Value List GroupAddresses (\S+) 9 | Value Mtu (\d+) 10 | 11 | Start 12 | ^${Interface} is ${Admin}, line protocol is ${Oper} 13 | ^.*link-local address is ${LinkLocal} 14 | ^ Description: ${Description} 15 | ^ Global unicast address -> Unicast 16 | ^ Joined group address -> Multicast 17 | ^ MTU is ${Mtu} bytes -> Record 18 | 19 | Unicast 20 | ^ ${Addresses}, subnet is ${Subnets} 21 | ^ Joined group address -> Multicast 22 | ^ \S -> Start 23 | 24 | Multicast 25 | ^ ${GroupAddresses} 26 | ^ MTU is ${Mtu} bytes -> Record 27 | ^ \S -> Start 28 | -------------------------------------------------------------------------------- /examples/cisco_version_example: -------------------------------------------------------------------------------- 1 | Cisco IOS Software, Catalyst 4500 L3 Switch Software (cat4500-ENTSERVICESK9-M), Version 12.2(31)SGA1, RELEASE SOFTWARE (fc3) 2 | Technical Support: http://www.cisco.com/techsupport 3 | Copyright (c) 1986-2007 by Cisco Systems, Inc. 4 | Compiled Fri 26-Jan-07 14:28 by kellythw 5 | Image text-base: 0x10000000, data-base: 0x118AD800 6 | 7 | ROM: 12.2(31r)SGA 8 | Pod Revision 0, Force Revision 34, Gill Revision 20 9 | 10 | router.abc uptime is 3 days, 13 hours, 53 minutes 11 | System returned to ROM by reload 12 | System restarted at 05:09:09 PDT Wed Apr 2 2008 13 | System image file is "bootflash:cat4500-entservicesk9-mz.122-31.SGA1.bin" 14 | 15 | 16 | This product contains cryptographic features and is subject to United 17 | States and local country laws governing import, export, transfer and 18 | use. Delivery of Cisco cryptographic products does not imply 19 | third-party authority to import, export, distribute or use encryption. 20 | Importers, exporters, distributors and users are responsible for 21 | compliance with U.S. and local country laws. By using this product you 22 | agree to comply with applicable laws and regulations. If you are unable 23 | to comply with U.S. and local laws, return this product immediately. 24 | 25 | A summary of U.S. laws governing Cisco cryptographic products may be found at: 26 | http://www.cisco.com/wwl/export/crypto/tool/stqrg.html 27 | 28 | If you require further assistance please contact us by sending email to export@cisco.com. 29 | 30 | cisco WS-C4948-10GE (MPC8540) processor (revision 5) with 262144K bytes of memory. 31 | Processor board ID FOX111700ZP 32 | MPC8540 CPU at 667Mhz, Fixed Module 33 | Last reset from Reload 34 | 2 Virtual Ethernet interfaces 35 | 48 Gigabit Ethernet interfaces 36 | 2 Ten Gigabit Ethernet interfaces 37 | 511K bytes of non-volatile configuration memory. 38 | 39 | Configuration register is 0x2102 40 | 41 | -------------------------------------------------------------------------------- /examples/cisco_version_template: -------------------------------------------------------------------------------- 1 | Value Model (\S+) 2 | Value Memory (\S+) 3 | Value ConfigRegister (0x\S+) 4 | Value Uptime (.*) 5 | Value Version (.*?) 6 | Value ReloadReason (.*) 7 | Value ReloadTime (.*) 8 | Value ImageFile ([^"]+) 9 | 10 | Start 11 | ^Cisco IOS Software.*Version ${Version}, 12 | ^.*uptime is ${Uptime} 13 | ^System returned to ROM by ${ReloadReason} 14 | ^System restarted at ${ReloadTime} 15 | ^System image file is "${ImageFile}" 16 | ^cisco ${Model} .* with ${Memory} bytes of memory 17 | ^Configuration register is ${ConfigRegister} 18 | -------------------------------------------------------------------------------- /examples/f10_ip_bgp_summary_example: -------------------------------------------------------------------------------- 1 | BGP router identifier 192.0.2.1, local AS number 65551 2 | BGP table version is 173711, main routing table version 173711 3 | 255 network entrie(s) using 43260 bytes of memory 4 | 1114 paths using 75752 bytes of memory 5 | BGP-RIB over all using 76866 bytes of memory 6 | 23 BGP path attribute entrie(s) using 1472 bytes of memory 7 | 3 BGP AS-PATH entrie(s) using 137 bytes of memory 8 | 10 BGP community entrie(s) using 498 bytes of memory 9 | 2 BGP route-reflector cluster entrie(s) using 62 bytes of memory 10 | 6 neighbor(s) using 28128 bytes of memory 11 | 12 | Neighbor AS MsgRcvd MsgSent TblVer InQ OutQ Up/Down State/Pfx 13 | 14 | 10.10.10.10 65551 647 397 73711 0 (0) 10:37:12 5 15 | 10.10.100.1 65552 664 416 73711 0 (0) 10:38:27 0 16 | 10.100.10.9 65553 709 526 73711 0 (0) 07:55:38 1 17 | -------------------------------------------------------------------------------- /examples/f10_ip_bgp_summary_template: -------------------------------------------------------------------------------- 1 | Value Filldown RouterID (\d+(\.\d+){3}) 2 | Value Filldown LocalAS (\d+) 3 | Value RemoteAS (\d+) 4 | Value Required RemoteIP (\d+(\.\d+){3}) 5 | Value Uptime (\S+) 6 | Value Received_V4 (\d+) 7 | Value Received_V6 () 8 | Value Status (\D.*) 9 | 10 | Start 11 | ^BGP router identifier ${RouterID}, local AS number ${LocalAS} 12 | ^${RemoteIP}\s+${RemoteAS}(\s+\S+){5}\s+${Uptime}\s+${Received_V4} -> Next.Record 13 | ^${RemoteIP}\s+${RemoteAS}(\s+\S+){5}\s+${Uptime}\s+${Status} -> Next.Record 14 | 15 | EOF 16 | -------------------------------------------------------------------------------- /examples/f10_version_example: -------------------------------------------------------------------------------- 1 | Force10 Networks Real Time Operating System Software 2 | Force10 Operating System Version: 1.0 3 | Force10 Application Software Version: 7.7.1.1 4 | Copyright (c) 1999-2008 by Force10 Networks, Inc. 5 | Build Time: Fri Sep 12 14:08:26 PDT 2008 6 | Build Path: /sites/sjc/work/sw/build/special_build/Release/E7-7-1/SW/SRC 7 | router.abc uptime is 3 day(s), 2 hour(s), 3 minute(s) 8 | 9 | System image file is "flash://FTOS-EF-7.7.1.1.bin" 10 | 11 | Chassis Type: E1200 12 | Control Processor: IBM PowerPC 750FX (Rev D2.2) with 536870912 bytes of memory. 13 | Route Processor 1: IBM PowerPC 750FX (Rev D2.2) with 1073741824 bytes of memory. 14 | Route Processor 2: IBM PowerPC 750FX (Rev D2.2) with 1073741824 bytes of memory. 15 | 16 | 128K bytes of non-volatile configuration memory. 17 | 18 | 1 Route Processor Module 19 | 9 Switch Fabric Module 20 | 1 48-port GE line card with SFP optics (EF) 21 | 7 4-port 10GE LAN/WAN PHY line card with XFP optics (EF) 22 | 1 FastEthernet/IEEE 802.3 interface(s) 23 | 48 GigabitEthernet/IEEE 802.3 interface(s) 24 | 28 Ten GigabitEthernet/IEEE 802.3 interface(s) 25 | 26 | -------------------------------------------------------------------------------- /examples/f10_version_template: -------------------------------------------------------------------------------- 1 | Value Chassis (\S+) 2 | Value Model (.*) 3 | Value Software (.*) 4 | Value Image ([^"]*) 5 | 6 | Start 7 | ^Force10 Application Software Version: ${Software} 8 | ^Chassis Type: ${Chassis} -> Continue 9 | ^Chassis Type: ${Model} 10 | ^System image file is "${Image}" 11 | -------------------------------------------------------------------------------- /examples/index: -------------------------------------------------------------------------------- 1 | 2 | # First line is the header fields for columns and is mandatory. 3 | # Regular expressions are supported in all fields except the first. 4 | # Last field supports variable length command completion. 5 | # abc[[xyz]] is expanded to abc(x(y(z)?)?)?, regexp inside [[]] is not supported 6 | # 7 | Template, Hostname, Vendor, Command 8 | cisco_bgp_summary_template, .*, Cisco, sh[[ow]] ip bg[[p]] su[[mmary]] 9 | cisco_version_template, .*, Cisco, sh[[ow]] ve[[rsion]] 10 | f10_ip_bgp_summary_template, .*, Force10, sh[[ow]] ip bg[[p]] sum[[mary]] 11 | f10_version_template, .*, Force10, sh[[ow]] ve[[rsion]] 12 | juniper_bgp_summary_template, .*, Juniper, sh[[ow]] bg[[p]] su[[mmary]] 13 | juniper_version_template, .*, Juniper, sh[[ow]] ve[[rsion]] 14 | unix_ifcfg_template, hostname[abc].*, .*, ifconfig 15 | -------------------------------------------------------------------------------- /examples/juniper_bgp_summary_example: -------------------------------------------------------------------------------- 1 | Groups: 3 Peers: 3 Down peers: 0 2 | Table Tot Paths Act Paths Suppressed History Damp State Pending 3 | inet.0 947 310 0 0 0 0 4 | inet6.0 849 807 0 0 0 0 5 | Peer AS InPkt OutPkt OutQ Flaps Last Up/Dwn State|#Active/Received/Damped... 6 | 10.247.68.182 65550 131725 28179233 0 11 6w3d17h Establ 7 | inet.0: 4/5/1 8 | inet6.0: 0/0/0 9 | 10.254.166.246 65550 136159 29104942 0 0 6w5d6h Establ 10 | inet.0: 0/0/0 11 | inet6.0: 7/8/1 12 | 192.0.2.100 65551 1269381 1363320 0 1 9w5d6h 2/3/0 0/0/0 13 | -------------------------------------------------------------------------------- /examples/juniper_bgp_summary_template: -------------------------------------------------------------------------------- 1 | Value RemoteAS (\d+) 2 | Value RemoteIP (\S+) 3 | Value Uptime (.*[0-9h]) 4 | Value Active_V4 (\d+) 5 | Value Received_V4 (\d+) 6 | Value Accepted_V4 (\d+) 7 | Value Damped_V4 (\d+) 8 | Value Active_V6 (\d+) 9 | Value Received_V6 (\d+) 10 | Value Accepted_V6 (\d+) 11 | Value Damped_V6 (\d+) 12 | Value Status ([a-zA-Z]+) 13 | 14 | Start 15 | # New format IPv4 & IPv6 split across newlines. 16 | ^\s+inet.0: ${Active_V4}/${Received_V4}/${Damped_V4} 17 | ^\s+inet6.0: ${Active_V6}/${Received_V6}/${Damped_V6} 18 | ^ -> Continue.Record 19 | ^${RemoteIP}\s+${RemoteAS}(\s+\d+){4}\s+${Uptime}\s+${Status} 20 | ^${RemoteIP}\s+${RemoteAS}(\s+\d+){4}\s+${Uptime}\s+${Active_V4}/${Received_V4}/${Damped_V4}\s+${Active_V6}/${Received_V6}/${Damped_V6} -> Next.Record 21 | ^${RemoteIP}\s+${RemoteAS}(\s+\d+){4}\s+${Uptime}\s+${Active_V4}/${Received_V4}/${Accepted_V4}/${Damped_V4}\s+${Active_V6}/${Received_V6}/${Accepted_V6}/${Damped_V6} -> Next.Record 22 | ^${RemoteIP}\s+${RemoteAS}(\s+\d+){4}\s+${Uptime}\s+${Status} -> Next.Record 23 | -------------------------------------------------------------------------------- /examples/juniper_version_example: -------------------------------------------------------------------------------- 1 | Hostname: router.abc 2 | Model: mx960 3 | JUNOS Base OS boot [9.1S3.5] 4 | JUNOS Base OS Software Suite [9.1S3.5] 5 | JUNOS Kernel Software Suite [9.1S3.5] 6 | JUNOS Crypto Software Suite [9.1S3.5] 7 | JUNOS Packet Forwarding Engine Support (M/T Common) [9.1S3.5] 8 | JUNOS Packet Forwarding Engine Support (MX Common) [9.1S3.5] 9 | JUNOS Online Documentation [9.1S3.5] 10 | JUNOS Routing Software Suite [9.1S3.5] 11 | -------------------------------------------------------------------------------- /examples/juniper_version_template: -------------------------------------------------------------------------------- 1 | Value Chassis (\S+) 2 | Value Required Model (\S+) 3 | Value Boot (.*) 4 | Value Base (.*) 5 | Value Kernel (.*) 6 | Value Crypto (.*) 7 | Value Documentation (.*) 8 | Value Routing (.*) 9 | 10 | Start 11 | # Support multiple chassis systems. 12 | ^\S+:$$ -> Continue.Record 13 | ^${Chassis}:$$ 14 | ^Model: ${Model} 15 | ^JUNOS Base OS boot \[${Boot}\] 16 | ^JUNOS Software Release \[${Base}\] 17 | ^JUNOS Base OS Software Suite \[${Base}\] 18 | ^JUNOS Kernel Software Suite \[${Kernel}\] 19 | ^JUNOS Crypto Software Suite \[${Crypto}\] 20 | ^JUNOS Online Documentation \[${Documentation}\] 21 | ^JUNOS Routing Software Suite \[${Routing}\] 22 | -------------------------------------------------------------------------------- /examples/unix_ifcfg_example: -------------------------------------------------------------------------------- 1 | lo0: flags=8049 mtu 16384 2 | inet6 ::1 prefixlen 128 3 | inet6 fe80::1%lo0 prefixlen 64 scopeid 0x1 4 | inet 127.0.0.1 netmask 0xff000000 5 | en0: flags=8863 mtu 1500 6 | ether 34:15:9e:27:45:e3 7 | inet6 fe80::3615:9eff:fe27:45e3%en0 prefixlen 64 scopeid 0x4 8 | inet6 2001:db8::3615:9eff:fe27:45e3 prefixlen 64 autoconf 9 | inet 192.0.2.215 netmask 0xfffffe00 broadcast 192.0.2.255 10 | media: autoselect (1000baseT ) 11 | status: active 12 | en1: flags=8863 mtu 1500 13 | ether 90:84:0d:f6:d1:55 14 | media: () 15 | status: inactive 16 | 17 | -------------------------------------------------------------------------------- /examples/unix_ifcfg_template: -------------------------------------------------------------------------------- 1 | Value Required Interface ([^:]+) 2 | Value MTU (\d+) 3 | Value State ((in)?active) 4 | Value MAC ([\d\w:]+) 5 | Value List Inet ([\d\.]+) 6 | Value List Netmask (\S+) 7 | # Don't match interface local (fe80::/10) - achieved with excluding '%'. 8 | Value List Inet6 ([^%]+) 9 | Value List Prefix (\d+) 10 | 11 | Start 12 | # Record interface record (if we have one). 13 | ^\S+:.* -> Continue.Record 14 | # Collect data for new interface. 15 | ^${Interface}:.* mtu ${MTU} 16 | ^\s+ether ${MAC} 17 | ^\s+inet6 ${Inet6} prefixlen ${Prefix} 18 | ^\s+inet ${Inet} netmask ${Netmask} 19 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | 4 | [aliases] 5 | test=pytest 6 | 7 | [bdist_wheel] 8 | universal=1 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | # Copyright 2017 Google Inc. All Rights Reserved. 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 | """Setup script.""" 18 | 19 | # To use a consistent encoding 20 | from codecs import open 21 | from os import path 22 | from setuptools import find_packages, setup 23 | import textfsm 24 | 25 | here = path.abspath(path.dirname(__file__)) 26 | 27 | # Get the long description from the README file 28 | with open(path.join(here, 'README.md'), encoding='utf8') as f: 29 | long_description = f.read() 30 | 31 | setup( 32 | name='textfsm', 33 | maintainer='Google', 34 | maintainer_email='textfsm-dev@googlegroups.com', 35 | version=textfsm.__version__, 36 | description=( 37 | 'Python module for parsing semi-structured text into python tables.' 38 | ), 39 | long_description=long_description, 40 | long_description_content_type='text/markdown', 41 | url='https://github.com/google/textfsm', 42 | license='Apache License, Version 2.0', 43 | classifiers=[ 44 | 'Development Status :: 5 - Production/Stable', 45 | 'Intended Audience :: Developers', 46 | 'License :: OSI Approved :: Apache Software License', 47 | 'Operating System :: OS Independent', 48 | 'Programming Language :: Python :: 3', 49 | 'Topic :: Software Development :: Libraries', 50 | ], 51 | packages=['textfsm'], 52 | entry_points={'console_scripts': ['textfsm=textfsm.parser:main']}, 53 | include_package_data=True, 54 | package_data={'textfsm': ['../testdata/*']}, 55 | ) 56 | -------------------------------------------------------------------------------- /testdata/clitable_templateA: -------------------------------------------------------------------------------- 1 | Value Key Col1 (.) 2 | Value Col2 (.) 3 | Value Col3 (.) 4 | 5 | Start 6 | ^${Col1} ${Col2} ${Col3} -> Record 7 | -------------------------------------------------------------------------------- /testdata/clitable_templateB: -------------------------------------------------------------------------------- 1 | Value Key Col1 (.) 2 | Value Col4 (.) 3 | 4 | Start 5 | ^${Col1} ${Col4} -> Record 6 | -------------------------------------------------------------------------------- /testdata/clitable_templateC: -------------------------------------------------------------------------------- 1 | Value Col1 (a) 2 | Value Col2 (.) 3 | Value Col3 (.) 4 | 5 | Start 6 | ^${Col1} ${Col2} ${Col3} -> Record 7 | -------------------------------------------------------------------------------- /testdata/clitable_templateD: -------------------------------------------------------------------------------- 1 | Value Col1 (d) 2 | Value Col2 (.) 3 | Value Col3 (.) 4 | 5 | Start 6 | ^${Col1} ${Col2} ${Col3} -> Record 7 | -------------------------------------------------------------------------------- /testdata/default_index: -------------------------------------------------------------------------------- 1 | # First line is header fields for columns 2 | # Regular expressions are supported in all fields except the first. 3 | # Last field supports variable length command completion. 4 | # abc[[xyz]] is expended to abc(x(y(z)?)?)?, regexp inside [[]] i not supported 5 | # 6 | Template, Hostname, Vendor, Command 7 | # 8 | clitable_templateA:clitable_templateB, .*, VendorA, sh[[ow]] ve[[rsion]] 9 | clitable_templateC, .*, VendorB, sh[[ow]] ve[[rsion]] 10 | clitable_templateD, .*, VendorA, sh[[ow]] in[[terfaces]] 11 | -------------------------------------------------------------------------------- /testdata/nondefault_index: -------------------------------------------------------------------------------- 1 | # First line is header fields for columns 2 | # Regular expressions are supported in all fields except the first. 3 | # Last field supports variable length command completion. 4 | # abc[[xyz]] is expended to abc(x(y(z)?)?)?, regexp inside [[]] i not supported 5 | # 6 | Devicename, Vendor, Command 7 | # 8 | .*, VendorA, sh[[ow]] ve[[rsion]] 9 | .*, VendorB, sh[[ow]] ve[[rsion]] 10 | .*, VendorA, sh[[ow]] in[[terfaces]] 11 | -------------------------------------------------------------------------------- /testdata/parseindex_index: -------------------------------------------------------------------------------- 1 | # First line is header fields for columns 2 | # Regular expressions are supported in all fields except the first. 3 | # Last field supports variable length command completion. 4 | # abc[[xyz]] is expended to abc(x(y(z)?)?)?, regexp inside [[]] i not supported 5 | # 6 | Template, Hostname, Vendor, Command 7 | # 8 | clitable_templateA:clitable_templateB, .*, VendorA, sh[[ow]] ve[[rsion]] 9 | clitable_templateC, .*, VendorB, sh[[ow]] ve[[rsion]] 10 | clitable_templateD, .*, VendorA, sh[[ow]] in[[terfaces]] 11 | -------------------------------------------------------------------------------- /testdata/parseindexfail1_index: -------------------------------------------------------------------------------- 1 | # First line is header fields for columns 2 | # Regular expressions are supported in all fields except the first. 3 | # Last field supports variable length command completion. 4 | # abc[[xyz]] is expended to abc(x(y(z)?)?)?, regexp inside [[]] i not supported 5 | # 6 | # Type in header name. 7 | Templatebogus, Hostname, Vendor, Command 8 | # 9 | clitable_templateA:clitable_templateB, .*, VendorA, sh[[ow]] ve[[rsion]] 10 | clitable_templateC, .*, VendorB, sh[[ow]] ve[[rsion]] 11 | clitable_templateD, .*, VendorA, sh[[ow]] in[[terfaces]] 12 | -------------------------------------------------------------------------------- /testdata/parseindexfail2_index: -------------------------------------------------------------------------------- 1 | # First line is header fields for columns 2 | # Regular expressions are supported in all fields except the first. 3 | # Last field supports variable length command completion. 4 | # abc[[xyz]] is expended to abc(x(y(z)?)?)?, regexp inside [[]] i not supported 5 | # 6 | # Column out of order 7 | Hostname, Template, Vendor, Command 8 | # 9 | clitable_templateA:clitable_templateB, .*, VendorA, sh[[ow]] ve[[rsion]] 10 | clitable_templateC, .*, VendorB, sh[[ow]] ve[[rsion]] 11 | clitable_templateD, .*, VendorA, sh[[ow]] in[[terfaces]] 12 | -------------------------------------------------------------------------------- /testdata/parseindexfail3_index: -------------------------------------------------------------------------------- 1 | # First line is header fields for columns 2 | # Regular expressions are supported in all fields except the first. 3 | # Last field supports variable length command completion. 4 | # abc[[xyz]] is expended to abc(x(y(z)?)?)?, regexp inside [[]] i not supported 5 | # 6 | Template, Hostname, Vendor, Command 7 | # 8 | # Illegal regexp characters in column. 9 | clitable_templateA:clitable_templateB, .*, VendorA, sh[[ow]] ve[[rsion]] 10 | clitable_templateC, .*, [[VendorB, sh[[ow]] ve[[rsion]] 11 | clitable_templateD, .*, VendorA, sh[[ow]] in[[terfaces]] 12 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/textfsm/f80bbb459c55ff5f21651e48d2529722d667af97/tests/__init__.py -------------------------------------------------------------------------------- /tests/clitable_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | # Copyright 2012 Google Inc. All Rights Reserved. 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 14 | # implied. See the License for the specific language governing 15 | # permissions and limitations under the License. 16 | 17 | """Unittest for clitable script.""" 18 | 19 | import copy 20 | import io 21 | import os 22 | import re 23 | import unittest 24 | from textfsm import clitable 25 | 26 | 27 | class UnitTestIndexTable(unittest.TestCase): 28 | """Tests the IndexTable class.""" 29 | 30 | def testParseIndex(self): 31 | """Test reading and index and parsing to index and compiled tables.""" 32 | file_path = os.path.join('testdata', 'parseindex_index') 33 | indx = clitable.IndexTable(file_path=file_path) 34 | # Compare number of entries found in the index table. 35 | self.assertEqual(indx.index.size, 3) 36 | self.assertEqual(indx.index[2]['Template'], 'clitable_templateC') 37 | self.assertEqual(indx.index[3]['Template'], 'clitable_templateD') 38 | self.assertEqual(indx.index[1]['Command'], 'sh[[ow]] ve[[rsion]]') 39 | self.assertEqual(indx.index[1]['Hostname'], '.*') 40 | 41 | self.assertEqual(indx.compiled.size, 3) 42 | for col in ('Command', 'Vendor', 'Template', 'Hostname'): 43 | self.assertIsInstance(indx.compiled[1][col], re.Pattern) 44 | 45 | self.assertTrue(indx.compiled[1]['Hostname'].match('random string')) 46 | 47 | def _PreParse(key, value): 48 | if key == 'Template': 49 | return value.upper() 50 | return value 51 | 52 | def _PreCompile(key, value): 53 | if key in ('Template', 'Command'): 54 | return None 55 | return value 56 | 57 | self.assertEqual(indx.compiled.size, 3) 58 | indx = clitable.IndexTable(_PreParse, _PreCompile, file_path) 59 | self.assertEqual(indx.index[2]['Template'], 'CLITABLE_TEMPLATEC') 60 | self.assertEqual(indx.index[1]['Command'], 'sh[[ow]] ve[[rsion]]') 61 | self.assertIsInstance(indx.compiled[1]['Hostname'], re.Pattern) 62 | self.assertFalse(indx.compiled[1]['Command']) 63 | 64 | def testGetRowMatch(self): 65 | """Tests retreiving rows from table.""" 66 | file_path = os.path.join('testdata', 'parseindex_index') 67 | indx = clitable.IndexTable(file_path=file_path) 68 | self.assertEqual(1, indx.GetRowMatch({'Hostname': 'abc'})) 69 | self.assertEqual(2, indx.GetRowMatch({'Hostname': 'abc', 70 | 'Vendor': 'VendorB'})) 71 | 72 | def testCopy(self): 73 | """Tests copy of IndexTable object.""" 74 | file_path = os.path.join('testdata', 'parseindex_index') 75 | indx = clitable.IndexTable(file_path=file_path) 76 | copy.deepcopy(indx) 77 | 78 | 79 | class UnitTestCliTable(unittest.TestCase): 80 | """Tests the CliTable class.""" 81 | 82 | def setUp(self): 83 | super(UnitTestCliTable, self).setUp() 84 | clitable.CliTable.INDEX = {} 85 | self.clitable = clitable.CliTable('default_index', 'testdata') 86 | self.input_data = ('a b c\n' 87 | 'd e f\n') 88 | self.template = ('Value Key Col1 (.)\n' 89 | 'Value Col2 (.)\n' 90 | 'Value Col3 (.)\n' 91 | '\n' 92 | 'Start\n' 93 | ' ^${Col1} ${Col2} ${Col3} -> Record\n' 94 | '\n') 95 | self.template_file = io.StringIO(self.template) 96 | 97 | def testCompletion(self): 98 | """Tests '[[]]' syntax replacement.""" 99 | indx = clitable.CliTable() 100 | self.assertEqual('abc', re.sub(r'(\[\[.+?\]\])', indx._Completion, 'abc')) 101 | self.assertEqual('a(b(c)?)?', 102 | re.sub(r'(\[\[.+?\]\])', indx._Completion, 'a[[bc]]')) 103 | self.assertEqual('a(b(c)?)? de(f)?', 104 | re.sub(r'(\[\[.+?\]\])', indx._Completion, 105 | 'a[[bc]] de[[f]]')) 106 | 107 | def testRepeatRead(self): 108 | """Tests that index file is read only once at the class level.""" 109 | new_clitable = clitable.CliTable('default_index', 'testdata') 110 | self.assertEqual(self.clitable.index, new_clitable.index) 111 | 112 | def testCliCompile(self): 113 | """Tests PreParse and PreCompile.""" 114 | 115 | self.assertEqual('sh(o(w)?)? ve(r(s(i(o(n)?)?)?)?)?', 116 | self.clitable.index.index[1]['Command']) 117 | self.assertIsNone(self.clitable.index.compiled[1]['Template']) 118 | self.assertTrue( 119 | self.clitable.index.compiled[1]['Command'].match('sho vers')) 120 | 121 | def testParseCmdItem(self): 122 | """Tests parsing data with a single specific template.""" 123 | t = self.clitable._ParseCmdItem(self.input_data, 124 | template_file=self.template_file) 125 | self.assertEqual(t.table, 'Col1, Col2, Col3\na, b, c\nd, e, f\n') 126 | 127 | def testParseCmd(self): 128 | """Tests parsing data with a mocked template.""" 129 | # Stub out the conversion of filename to file handle. 130 | self.clitable._TemplateNamesToFiles = lambda t: [self.template_file] 131 | self.clitable.ParseCmd(self.input_data, attributes={'Command': 'sh vers'}) 132 | self.assertEqual( 133 | self.clitable.table, 'Col1, Col2, Col3\na, b, c\nd, e, f\n') 134 | 135 | def testParseWithTemplate(self): 136 | """Tests parsing with an explicitly declared the template.""" 137 | self.clitable.ParseCmd(self.input_data, 138 | attributes={'Command': 'sh vers'}, 139 | templates='clitable_templateB') 140 | self.assertEqual( 141 | self.clitable.table, 'Col1, Col4\na, b\nd, e\n') 142 | 143 | def testParseCmdFromIndex(self): 144 | """Tests parsing with a template found in the index.""" 145 | self.clitable.ParseCmd(self.input_data, 146 | attributes={'Command': 'sh vers', 147 | 'Vendor': 'VendorB'}) 148 | self.assertEqual( 149 | self.clitable.table, 'Col1, Col2, Col3\na, b, c\n') 150 | self.clitable.ParseCmd(self.input_data, 151 | attributes={'Command': 'sh int', 152 | 'Vendor': 'VendorA'}) 153 | self.assertEqual( 154 | self.clitable.table, 'Col1, Col2, Col3\nd, e, f\n') 155 | 156 | self.assertRaises(clitable.CliTableError, self.clitable.ParseCmd, 157 | self.input_data, 158 | attributes={'Command': 'show vers', 159 | 'Vendor': 'bogus'}) 160 | self.assertRaises(clitable.CliTableError, self.clitable.ParseCmd, 161 | self.input_data, 162 | attributes={'Command': 'unknown command', 163 | 'Vendor': 'VendorA'}) 164 | 165 | def testParseWithMultiTemplates(self): 166 | """Tests that multiple matching templates extend the table.""" 167 | self.clitable.ParseCmd(self.input_data, 168 | attributes={'Command': 'sh ver', 169 | 'Vendor': 'VendorA'}) 170 | self.assertEqual( 171 | self.clitable.table, 172 | 'Col1, Col2, Col3, Col4\na, b, c, b\nd, e, f, e\n') 173 | self.clitable.ParseCmd(self.input_data, 174 | attributes={'Command': 'sh vers'}, 175 | templates='clitable_templateB:clitable_templateA') 176 | self.assertEqual( 177 | self.clitable.table, 178 | 'Col1, Col4, Col2, Col3\na, b, b, c\nd, e, e, f\n') 179 | self.assertRaises(IOError, self.clitable.ParseCmd, 180 | self.input_data, 181 | attributes={'Command': 'sh vers'}, 182 | templates='clitable_templateB:clitable_bogus') 183 | 184 | def testRequireCols(self): 185 | """Tests that CliTable expects a 'Template' row to be present.""" 186 | self.assertRaises(clitable.CliTableError, clitable.CliTable, 187 | 'nondefault_index', 'testdata') 188 | 189 | def testSuperKey(self): 190 | """Tests that superkey is derived from the template and is extensible.""" 191 | # Stub out the conversion of filename to file handle. 192 | self.clitable._TemplateNamesToFiles = lambda t: [self.template_file] 193 | self.clitable.ParseCmd(self.input_data, attributes={'Command': 'sh ver'}) 194 | self.assertEqual(self.clitable.superkey, ['Col1']) 195 | self.assertEqual( 196 | self.clitable.LabelValueTable(), 197 | '# LABEL Col1\n' 198 | 'a.Col2 b\n' 199 | 'a.Col3 c\n' 200 | 'd.Col2 e\n' 201 | 'd.Col3 f\n') 202 | 203 | self.clitable.AddKeys(['Col2']) 204 | self.assertEqual( 205 | self.clitable.LabelValueTable(), 206 | '# LABEL Col1.Col2\n' 207 | 'a.b.Col3 c\n' 208 | 'd.e.Col3 f\n') 209 | 210 | def testAddKey(self): 211 | """Tests that new keys are not duplicated and non-existant columns.""" 212 | self.assertEqual(self.clitable.superkey, []) 213 | # Stub out the conversion of filename to file handle. 214 | self.clitable._TemplateNamesToFiles = lambda t: [self.template_file] 215 | self.clitable.ParseCmd(self.input_data, attributes={'Command': 'sh ver'}) 216 | self.assertEqual(self.clitable.superkey, ['Col1']) 217 | self.clitable.AddKeys(['Col1', 'Col2', 'Col3']) 218 | self.assertEqual(self.clitable.superkey, ['Col1', 'Col2', 'Col3']) 219 | self.assertRaises(KeyError, self.clitable.AddKeys, ['Bogus']) 220 | 221 | def testKeyValue(self): 222 | """Tests retrieving row value that corresponds to the key.""" 223 | # Stub out the conversion of filename to file handle. 224 | self.clitable._TemplateNamesToFiles = lambda t: [self.template_file] 225 | self.clitable.ParseCmd(self.input_data, attributes={'Command': 'sh ver'}) 226 | self.assertEqual(self.clitable.KeyValue(), ['a']) 227 | self.clitable.row_index = 2 228 | self.assertEqual(self.clitable.KeyValue(), ['d']) 229 | self.clitable.row_index = 1 230 | self.clitable.AddKeys(['Col3']) 231 | self.assertEqual(self.clitable.KeyValue(), ['a', 'c']) 232 | # With no key it falls back to row number. 233 | self.clitable._keys = set() 234 | for rownum, row in enumerate(self.clitable, start=1): 235 | self.assertEqual(row.table.KeyValue(), ['%s' % rownum]) 236 | 237 | def testTableSort(self): 238 | """Tests sorting of table based on superkey.""" 239 | self.clitable._TemplateNamesToFiles = lambda t: [self.template_file] 240 | input_data2 = ('a e c\n' 241 | 'd b f\n') 242 | self.clitable.ParseCmd(self.input_data + input_data2, 243 | attributes={'Command': 'sh ver'}) 244 | self.assertEqual( 245 | self.clitable.table, 246 | 'Col1, Col2, Col3\na, b, c\nd, e, f\na, e, c\nd, b, f\n') 247 | self.clitable.sort() 248 | # Key was non-unique, columns outside of the key do not count. 249 | self.assertEqual( 250 | self.clitable.table, 251 | 'Col1, Col2, Col3\na, b, c\na, e, c\nd, e, f\nd, b, f\n') 252 | 253 | # Create a new table with no explicit key. 254 | self.template = ('Value Col1 (.)\n' 255 | 'Value Col2 (.)\n' 256 | 'Value Col3 (.)\n' 257 | '\n' 258 | 'Start\n' 259 | ' ^${Col1} ${Col2} ${Col3} -> Record\n' 260 | '\n') 261 | self.template_file = io.StringIO(self.template) 262 | self.clitable._TemplateNamesToFiles = lambda t: [self.template_file] 263 | self.clitable.ParseCmd(self.input_data + input_data2, 264 | attributes={'Command': 'sh ver'}) 265 | # Add a manual key. 266 | self.clitable.AddKeys(['Col2']) 267 | self.clitable.sort() 268 | self.assertEqual( 269 | self.clitable.table, 270 | 'Col1, Col2, Col3\na, b, c\nd, b, f\nd, e, f\na, e, c\n') 271 | # Clear the keys. 272 | self.clitable._keys = set() 273 | # With no key, sort based on whole row. 274 | self.clitable.sort() 275 | self.assertEqual( 276 | self.clitable.table, 277 | 'Col1, Col2, Col3\na, b, c\na, e, c\nd, b, f\nd, e, f\n') 278 | 279 | def testCopy(self): 280 | """Tests copying of clitable object.""" 281 | copy.deepcopy(self.clitable) 282 | 283 | 284 | if __name__ == '__main__': 285 | unittest.main() 286 | -------------------------------------------------------------------------------- /tests/terminal_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | # Copyright 2011 Google Inc. All Rights Reserved. 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 | """Unittest for terminal module.""" 19 | 20 | import sys 21 | import unittest 22 | 23 | from textfsm import terminal 24 | 25 | 26 | class TerminalTest(unittest.TestCase): 27 | 28 | def setUp(self): 29 | super(TerminalTest, self).setUp() 30 | self._get_terminal_size_orig = terminal.shutil.get_terminal_size 31 | 32 | def tearDown(self): 33 | super(TerminalTest, self).tearDown() 34 | terminal.shutil.get_terminal_size = self._get_terminal_size_orig 35 | 36 | def testAnsiCmd(self): 37 | self.assertEqual('\033[0m', terminal._AnsiCmd(['reset'])) 38 | self.assertEqual('\033[0m', terminal._AnsiCmd(['RESET'])) 39 | self.assertEqual('\033[0;32m', terminal._AnsiCmd(['reset', 'Green'])) 40 | self.assertRaises(ValueError, terminal._AnsiCmd, ['bogus']) 41 | self.assertRaises(ValueError, terminal._AnsiCmd, ['reset', 'bogus']) 42 | 43 | def testAnsiText(self): 44 | self.assertEqual('\033[0mhello world\033[0m', 45 | terminal.AnsiText('hello world')) 46 | self.assertEqual('\033[31mhello world\033[0m', 47 | terminal.AnsiText('hello world', ['red'])) 48 | self.assertEqual('\033[31;46mhello world', 49 | terminal.AnsiText( 50 | 'hello world', ['red', 'bg_cyan'], False)) 51 | 52 | def testStripAnsi(self): 53 | text = 'ansi length' 54 | self.assertEqual(text, terminal.StripAnsiText(text)) 55 | ansi_text = '\033[5;32;44mansi\033[0m length' 56 | self.assertEqual(text, terminal.StripAnsiText(ansi_text)) 57 | 58 | def testEncloseAnsi(self): 59 | text = 'ansi length' 60 | self.assertEqual(text, terminal.EncloseAnsiText(text)) 61 | ansi_text = '\033[5;32;44mansi\033[0m length' 62 | ansi_enclosed = '\001\033[5;32;44m\002ansi\001\033[0m\002 length' 63 | self.assertEqual(ansi_enclosed, terminal.EncloseAnsiText(ansi_text)) 64 | 65 | def testLineWrap(self): 66 | terminal.shutil.get_terminal_size = lambda: (11, 5) 67 | text = '' 68 | self.assertEqual(text, terminal.LineWrap(text)) 69 | text = 'one line' 70 | self.assertEqual(text, terminal.LineWrap(text)) 71 | text = 'two\nlines' 72 | self.assertEqual(text, terminal.LineWrap(text)) 73 | text = 'one line that is too long' 74 | text2 = 'one line th\nat is too l\nong' 75 | self.assertEqual(text2, terminal.LineWrap(text)) 76 | # Counting ansi characters won't matter if there are none. 77 | self.assertEqual(text2, terminal.LineWrap(text, False)) 78 | text = 'one line \033[5;32;44mthat\033[0m is too long with ansi' 79 | text2 = 'one line \033[5;32;44mth\nat\033[0m is too l\nong with an\nsi' 80 | text3 = 'one line \033[\n5;32;44mtha\nt\033[0m is to\no long with\n ansi' 81 | # Ansi does not factor and the line breaks stay the same. 82 | self.assertEqual(text2, terminal.LineWrap(text, True)) 83 | # If we count the ansi escape as characters then the line breaks change. 84 | self.assertEqual(text3, terminal.LineWrap(text, False)) 85 | # False is implicit default. 86 | self.assertEqual(text3, terminal.LineWrap(text)) 87 | # Couple of edge cases where we split on token boundary. 88 | text4 = 'ooone line \033[5;32;44mthat\033[0m is too long with ansi' 89 | text5 = 'ooone line \033[5;32;44m\nthat\033[0m is too\n long with \nansi' 90 | self.assertEqual(text5, terminal.LineWrap(text4, True)) 91 | text6 = 'e line \033[5;32;44mthat\033[0m is too long with ansi' 92 | text7 = 'e line \033[5;32;44mthat\033[0m\n is too lon\ng with ansi' 93 | self.assertEqual(text7, terminal.LineWrap(text6, True)) 94 | 95 | def testIssue1(self): 96 | self.assertEqual(10, len(terminal.StripAnsiText('boembabies' '\033[0m'))) 97 | terminal.shutil.get_terminal_size = lambda: (10, 10) 98 | text1 = terminal.LineWrap('\033[32m' + 'boembabies, ' * 10 + 'boembabies' + 99 | '\033[0m', omit_sgr=True) 100 | text2 = ('\033[32m' + 101 | terminal.LineWrap('boembabies, ' * 10 + 'boembabies') + 102 | '\033[0m') 103 | self.assertEqual(text1, text2) 104 | 105 | 106 | class FakeTerminal(object): 107 | 108 | def __init__(self): 109 | self.output = '' 110 | 111 | # pylint: disable=C6409 112 | def write(self, text): 113 | # Ignore initial clear screen output. 114 | if text != '\033[2J\033[H': 115 | self.output += text 116 | 117 | # pylint: disable=C6409 118 | def CountLines(self): 119 | return len(self.output.splitlines()) 120 | 121 | def Show(self): 122 | return self.output 123 | 124 | def Clear(self): 125 | self.output = '' 126 | 127 | def flush(self): 128 | pass 129 | 130 | 131 | class PagerTest(unittest.TestCase): 132 | 133 | def setUp(self): 134 | super(PagerTest, self).setUp() 135 | self._output = FakeTerminal() 136 | sys.stdout = self._output 137 | self._getchar_orig = terminal._GetChar 138 | # Quit the pager immediately after the first page. 139 | terminal._GetChar = lambda: 'q' 140 | 141 | self._sample_text = '' 142 | for i in range(10): 143 | self._sample_text += str(i) + '\n' 144 | 145 | self.p = terminal.Pager(self._sample_text) 146 | # Both the Prompt, and the ClearPrompt need to be accounted for. 147 | self._prompt_lines = 2 148 | 149 | def tearDown(self): 150 | super(PagerTest, self).tearDown() 151 | terminal._GetChar = self._getchar_orig 152 | sys.stdout = sys.__stdout__ 153 | 154 | def testDisplay(self): 155 | # Display a couple of rows (20%). 156 | self.assertEqual(self.p._Display(0, 2), (2, 20.0, 10)) 157 | self.assertEqual(self._output.Show(), '0\n1\n') 158 | self._output.Clear() 159 | self.assertEqual(self.p._Display(3, 2), (5, 50.0, 10)) 160 | self.assertEqual(self._output.Show(), '3\n4\n') 161 | self._output.Clear() 162 | # Display past the end of the text. 163 | self.assertEqual(self.p._Display(8, 3), (10, 100.0, 10)) 164 | self.assertEqual(self._output.Show(), '8\n9\n') 165 | self._output.Clear() 166 | # Display before the start. Displays form the start. 167 | self.assertEqual(self.p._Display(-1, 2), (2, 20.0, 10)) 168 | self.assertEqual(self._output.Show(), '0\n1\n') 169 | self._output.Clear() 170 | # Display the rest of the text. 171 | self.assertEqual(self.p._Display(7), (10, 100.0, 10)) 172 | self.assertEqual(self._output.Show(), '7\n8\n9\n') 173 | 174 | def testPageAddsText(self): 175 | extra_text = '10\n11\n' 176 | self.p.Page(extra_text) 177 | self.assertEqual(self.p._text, self._sample_text + extra_text) 178 | 179 | def testPage(self): 180 | self.p.SetLines(3) 181 | self.p.Page() 182 | self.assertEqual( 183 | self._output.Show().splitlines()[:-self._prompt_lines], ['0', '1', '2']) 184 | 185 | def testPrompt(self): 186 | self.p.SetLines(2) 187 | # After paging once the progress will be 20%. 188 | self.p.Page() 189 | self._output.Clear() 190 | self.assertEqual(self.p._Prompt(), terminal.AnsiText( 191 | 'n: next line, Space: next page, b: prev page, q: quit.', 192 | ['green'])) 193 | # truncate width to 10 cols, prompt should be likewise truncated. 194 | self.p._cols = 10 195 | self.assertEqual(self.p._Prompt(), 196 | terminal.AnsiText('n: next li', ['green'])) 197 | 198 | def testPagerClear(self): 199 | self.p.SetLines(2) 200 | self.p.Page() 201 | self.p.Reset() 202 | # Clear output we aren't testing. 203 | self._output.Clear() 204 | # Paging after Reset resumes from the start. 205 | self.p.Page() 206 | self.assertEqual(self._output.Show().splitlines()[:-self._prompt_lines], 207 | ['0', '1']) 208 | 209 | def testPageAddPercent(self): 210 | self.p.SetLines(2) 211 | self.p.Page() 212 | self.assertEqual(terminal.StripAnsiText( 213 | self._output.Show().splitlines()[-self._prompt_lines]), 214 | terminal.PROMPT_QUESTION + ' (20%)') 215 | self.p.Page() 216 | self.assertEqual(terminal.StripAnsiText( 217 | self._output.Show().splitlines()[-self._prompt_lines]), 218 | terminal.PROMPT_QUESTION + ' (40%)') 219 | self.p.Page('10\n11\n') 220 | # 50%, rather than 60%, as the total size increased from 10 to 12. 221 | # But we don't show percent, as the source is streamed. 222 | self.assertEqual(terminal.StripAnsiText( 223 | self._output.Show().splitlines()[-self._prompt_lines]), 224 | terminal.PROMPT_QUESTION) 225 | self.p.Page('12\n13\n14\n15') 226 | self.assertEqual(terminal.StripAnsiText( 227 | self._output.Show().splitlines()[-self._prompt_lines]), 228 | terminal.PROMPT_QUESTION) 229 | self.p.Page() 230 | self.assertEqual(terminal.StripAnsiText( 231 | self._output.Show().splitlines()[-self._prompt_lines]), 232 | terminal.PROMPT_QUESTION + ' (%d%%)' % (10 / 16 * 100)) 233 | 234 | def testBlankLines(self): 235 | buffer = 'First line.\n\nThird line.\n' 236 | self.p = terminal.Pager(buffer) 237 | self.p.SetLines(4) 238 | self.p.Page() 239 | self.assertEqual(self._output.Show().splitlines()[:-self._prompt_lines], 240 | buffer.splitlines()) 241 | 242 | 243 | if __name__ == '__main__': 244 | unittest.main() 245 | -------------------------------------------------------------------------------- /tests/textfsm_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright 2010 Google Inc. All Rights Reserved. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 15 | # implied. See the License for the specific language governing 16 | # permissions and limitations under the License. 17 | 18 | """Unittest for textfsm module.""" 19 | 20 | import io 21 | import unittest 22 | 23 | import textfsm 24 | 25 | 26 | class UnitTestFSM(unittest.TestCase): 27 | """Tests the FSM engine.""" 28 | 29 | def testFSMValue(self): 30 | # Check basic line is parsed. 31 | line = r'Value beer (\S+)' 32 | v = textfsm.TextFSMValue() 33 | v.Parse(line) 34 | self.assertEqual(v.name, 'beer') 35 | self.assertEqual(v.regex, r'(\S+)') 36 | self.assertEqual(v.template, r'(?P\S+)') 37 | self.assertFalse(v.options) 38 | 39 | # Test options 40 | line = r'Value Filldown,Required beer (\S+)' 41 | v = textfsm.TextFSMValue(options_class=textfsm.TextFSMOptions) 42 | v.Parse(line) 43 | self.assertEqual(v.name, 'beer') 44 | self.assertEqual(v.regex, r'(\S+)') 45 | self.assertEqual(v.OptionNames(), ['Filldown', 'Required']) 46 | 47 | # Multiple parenthesis. 48 | v = textfsm.TextFSMValue(options_class=textfsm.TextFSMOptions) 49 | v.Parse('Value Required beer (boo(hoo))') 50 | self.assertEqual(v.name, 'beer') 51 | self.assertEqual(v.regex, '(boo(hoo))') 52 | self.assertEqual(v.template, '(?Pboo(hoo))') 53 | self.assertEqual(v.OptionNames(), ['Required']) 54 | 55 | # regex must be bounded by parenthesis. 56 | self.assertRaises( 57 | textfsm.TextFSMTemplateError, v.Parse, 'Value beer (boo(hoo)))boo' 58 | ) 59 | self.assertRaises( 60 | textfsm.TextFSMTemplateError, v.Parse, 'Value beer boo(boo(hoo)))' 61 | ) 62 | self.assertRaises( 63 | textfsm.TextFSMTemplateError, v.Parse, 'Value beer (boo)hoo)' 64 | ) 65 | 66 | # Escaped parentheses don't count. 67 | v = textfsm.TextFSMValue(options_class=textfsm.TextFSMOptions) 68 | v.Parse(r'Value beer (boo\)hoo)') 69 | self.assertEqual(v.name, 'beer') 70 | self.assertEqual(v.regex, r'(boo\)hoo)') 71 | self.assertRaises( 72 | textfsm.TextFSMTemplateError, v.Parse, r'Value beer (boohoo\)' 73 | ) 74 | self.assertRaises( 75 | textfsm.TextFSMTemplateError, v.Parse, r'Value beer (boo)hoo\)' 76 | ) 77 | 78 | # Unbalanced parenthesis can exist if within square "[]" braces. 79 | v = textfsm.TextFSMValue(options_class=textfsm.TextFSMOptions) 80 | v.Parse('Value beer (boo[(]hoo)') 81 | self.assertEqual(v.name, 'beer') 82 | self.assertEqual(v.regex, '(boo[(]hoo)') 83 | 84 | # Escaped braces don't count. 85 | self.assertRaises( 86 | textfsm.TextFSMTemplateError, v.Parse, r'Value beer (boo\[)\]hoo)' 87 | ) 88 | 89 | # String function. 90 | v = textfsm.TextFSMValue(options_class=textfsm.TextFSMOptions) 91 | v.Parse('Value Required beer (boo(hoo))') 92 | self.assertEqual(str(v), 'Value Required beer (boo(hoo))') 93 | v = textfsm.TextFSMValue(options_class=textfsm.TextFSMOptions) 94 | v.Parse(r'Value Required,Filldown beer (bo\S+(hoo))') 95 | self.assertEqual(str(v), r'Value Required,Filldown beer (bo\S+(hoo))') 96 | 97 | def testFSMRule(self): 98 | 99 | # Basic line, no action 100 | line = ' ^A beer called ${beer}' 101 | r = textfsm.TextFSMRule(line) 102 | self.assertEqual(r.match, '^A beer called ${beer}') 103 | self.assertEqual(r.line_op, '') 104 | self.assertEqual(r.new_state, '') 105 | self.assertEqual(r.record_op, '') 106 | # Multiple matches 107 | line = ' ^A $hi called ${beer}' 108 | r = textfsm.TextFSMRule(line) 109 | self.assertEqual(r.match, '^A $hi called ${beer}') 110 | self.assertEqual(r.line_op, '') 111 | self.assertEqual(r.new_state, '') 112 | self.assertEqual(r.record_op, '') 113 | 114 | # Line with action. 115 | line = ' ^A beer called ${beer} -> Next' 116 | r = textfsm.TextFSMRule(line) 117 | self.assertEqual(r.match, '^A beer called ${beer}') 118 | self.assertEqual(r.line_op, 'Next') 119 | self.assertEqual(r.new_state, '') 120 | self.assertEqual(r.record_op, '') 121 | 122 | # Line with record. 123 | line = ' ^A beer called ${beer} -> Continue.Record' 124 | r = textfsm.TextFSMRule(line) 125 | self.assertEqual(r.match, '^A beer called ${beer}') 126 | self.assertEqual(r.line_op, 'Continue') 127 | self.assertEqual(r.new_state, '') 128 | self.assertEqual(r.record_op, 'Record') 129 | 130 | # Line with new state. 131 | line = ' ^A beer called ${beer} -> Next.NoRecord End' 132 | r = textfsm.TextFSMRule(line) 133 | self.assertEqual(r.match, '^A beer called ${beer}') 134 | self.assertEqual(r.line_op, 'Next') 135 | self.assertEqual(r.new_state, 'End') 136 | self.assertEqual(r.record_op, 'NoRecord') 137 | 138 | # Bad syntax tests. 139 | self.assertRaises( 140 | textfsm.TextFSMTemplateError, 141 | textfsm.TextFSMRule, 142 | ' ^A beer called ${beer} -> Next Next Next', 143 | ) 144 | self.assertRaises( 145 | textfsm.TextFSMTemplateError, 146 | textfsm.TextFSMRule, 147 | ' ^A beer called ${beer} -> Boo.hoo', 148 | ) 149 | self.assertRaises( 150 | textfsm.TextFSMTemplateError, 151 | textfsm.TextFSMRule, 152 | ' ^A beer called ${beer} -> Continue.Record $Hi', 153 | ) 154 | 155 | def testRulePrefixes(self): 156 | """Test valid and invalid rule prefixes.""" 157 | 158 | # Bad syntax tests. 159 | for prefix in (' ', '.^', ' \t', ''): 160 | f = io.StringIO( 161 | 'Value unused (.)\n\nStart\n' + prefix + 'A simple string.' 162 | ) 163 | self.assertRaises(textfsm.TextFSMTemplateError, textfsm.TextFSM, f) 164 | 165 | # Good syntax tests. 166 | for prefix in (' ^', ' ^', '\t^'): 167 | f = io.StringIO( 168 | 'Value unused (.)\n\nStart\n' + prefix + 'A simple string.' 169 | ) 170 | self.assertIsNotNone(textfsm.TextFSM(f)) 171 | 172 | def testImplicitDefaultRules(self): 173 | 174 | for line in ( 175 | ' ^A beer called ${beer} -> Record End', 176 | ' ^A beer called ${beer} -> End', 177 | ' ^A beer called ${beer} -> Next.NoRecord End', 178 | ' ^A beer called ${beer} -> Clear End', 179 | ' ^A beer called ${beer} -> Error "Hello World"', 180 | ): 181 | r = textfsm.TextFSMRule(line) 182 | self.assertEqual(str(r), line) 183 | 184 | for line in ( 185 | ' ^A beer called ${beer} -> Next "Hello World"', 186 | ' ^A beer called ${beer} -> Record.Next', 187 | ' ^A beer called ${beer} -> Continue End', 188 | ' ^A beer called ${beer} -> Beer End', 189 | ): 190 | self.assertRaises(textfsm.TextFSMTemplateError, textfsm.TextFSMRule, line) 191 | 192 | def testSpacesAroundAction(self): 193 | for line in ( 194 | ' ^Hello World -> Boo', 195 | ' ^Hello World -> Boo', 196 | ' ^Hello World -> Boo', 197 | ): 198 | self.assertEqual(str(textfsm.TextFSMRule(line)), ' ^Hello World -> Boo') 199 | 200 | # A '->' without a leading space is considered part of the matching line. 201 | self.assertEqual( 202 | ' A simple line-> Boo -> Next', 203 | str(textfsm.TextFSMRule(' A simple line-> Boo -> Next')), 204 | ) 205 | 206 | def testParseFSMVariables(self): 207 | # Trivial template to initiate object. 208 | f = io.StringIO('Value unused (.)\n\nStart\n') 209 | t = textfsm.TextFSM(f) 210 | 211 | # Trivial entry 212 | buf = 'Value Filldown Beer (beer)\n\n' 213 | f = io.StringIO(buf) 214 | t._ParseFSMVariables(f) 215 | 216 | # Single variable with commented header. 217 | buf = '# Headline\nValue Filldown Beer (beer)\n\n' 218 | f = io.StringIO(buf) 219 | t._ParseFSMVariables(f) 220 | self.assertEqual(str(t._GetValue('Beer')), 'Value Filldown Beer (beer)') 221 | 222 | # Multiple variables. 223 | buf = ( 224 | '# Headline\n' 225 | 'Value Filldown Beer (beer)\n' 226 | 'Value Required Spirits (whiskey)\n' 227 | 'Value Filldown Wine (claret)\n' 228 | '\n' 229 | ) 230 | t._line_num = 0 231 | f = io.StringIO(buf) 232 | t._ParseFSMVariables(f) 233 | self.assertEqual(str(t._GetValue('Beer')), 'Value Filldown Beer (beer)') 234 | self.assertEqual( 235 | str(t._GetValue('Spirits')), 'Value Required Spirits (whiskey)' 236 | ) 237 | self.assertEqual(str(t._GetValue('Wine')), 'Value Filldown Wine (claret)') 238 | 239 | # Multiple variables. 240 | buf = ( 241 | '# Headline\n' 242 | 'Value Filldown Beer (beer)\n' 243 | ' # A comment\n' 244 | 'Value Spirits ()\n' 245 | 'Value Filldown,Required Wine ((c|C)laret)\n' 246 | '\n' 247 | ) 248 | 249 | f = io.StringIO(buf) 250 | t._ParseFSMVariables(f) 251 | self.assertEqual(str(t._GetValue('Beer')), 'Value Filldown Beer (beer)') 252 | self.assertEqual(str(t._GetValue('Spirits')), 'Value Spirits ()') 253 | self.assertEqual( 254 | str(t._GetValue('Wine')), 'Value Filldown,Required Wine ((c|C)laret)' 255 | ) 256 | 257 | # Malformed variables. 258 | buf = 'Value Beer (beer) beer' 259 | f = io.StringIO(buf) 260 | self.assertRaises(textfsm.TextFSMTemplateError, t._ParseFSMVariables, f) 261 | 262 | buf = 'Value Filldown, Required Spirits ()' 263 | f = io.StringIO(buf) 264 | self.assertRaises(textfsm.TextFSMTemplateError, t._ParseFSMVariables, f) 265 | buf = 'Value filldown,Required Wine ((c|C)laret)' 266 | f = io.StringIO(buf) 267 | self.assertRaises(textfsm.TextFSMTemplateError, t._ParseFSMVariables, f) 268 | 269 | # Values that look bad but are OK. 270 | buf = ( 271 | '# Headline\n' 272 | 'Value Filldown Beer (bee(r), (and) (M)ead$)\n' 273 | '# A comment\n' 274 | 'Value Spirits,and,some ()\n' 275 | 'Value Filldown,Required Wine ((c|C)laret)\n' 276 | '\n' 277 | ) 278 | f = io.StringIO(buf) 279 | t._ParseFSMVariables(f) 280 | self.assertEqual( 281 | str(t._GetValue('Beer')), 'Value Filldown Beer (bee(r), (and) (M)ead$)' 282 | ) 283 | self.assertEqual( 284 | str(t._GetValue('Spirits,and,some')), 'Value Spirits,and,some ()' 285 | ) 286 | self.assertEqual( 287 | str(t._GetValue('Wine')), 'Value Filldown,Required Wine ((c|C)laret)' 288 | ) 289 | 290 | # Variable name too long. 291 | buf = ( 292 | 'Value Filldown ' 293 | 'nametoolong_nametoolong_nametoolo_nametoolong_nametoolong ' 294 | '(beer)\n\n' 295 | ) 296 | f = io.StringIO(buf) 297 | self.assertRaises(textfsm.TextFSMTemplateError, t._ParseFSMVariables, f) 298 | 299 | def testParseFSMState(self): 300 | 301 | f = io.StringIO('Value Beer (.)\nValue Wine (\\w)\n\nStart\n') 302 | t = textfsm.TextFSM(f) 303 | 304 | # Fails as we already have 'Start' state. 305 | buf = 'Start\n ^.\n' 306 | f = io.StringIO(buf) 307 | self.assertRaises(textfsm.TextFSMTemplateError, t._ParseFSMState, f) 308 | 309 | # Remove start so we can test new Start state. 310 | t.states = {} 311 | 312 | # Single state. 313 | buf = '# Headline\nStart\n ^.\n\n' 314 | f = io.StringIO(buf) 315 | t._ParseFSMState(f) 316 | self.assertEqual(str(t.states['Start'][0]), ' ^.') 317 | try: 318 | _ = t.states['Start'][1] 319 | except IndexError: 320 | pass 321 | 322 | # Multiple states. 323 | buf = '# Headline\nStart\n ^.\n ^Hello World\n ^Last-[Cc]ha$$nge\n' 324 | f = io.StringIO(buf) 325 | t._line_num = 0 326 | t.states = {} 327 | t._ParseFSMState(f) 328 | self.assertEqual(str(t.states['Start'][0]), ' ^.') 329 | self.assertEqual(str(t.states['Start'][1]), ' ^Hello World') 330 | self.assertEqual(t.states['Start'][1].line_num, 4) 331 | self.assertEqual(str(t.states['Start'][2]), ' ^Last-[Cc]ha$$nge') 332 | try: 333 | _ = t.states['Start'][3] 334 | except IndexError: 335 | pass 336 | 337 | t.states = {} 338 | # Malformed states. 339 | buf = 'St%art\n ^.\n ^Hello World\n' 340 | f = io.StringIO(buf) 341 | self.assertRaises(textfsm.TextFSMTemplateError, t._ParseFSMState, f) 342 | 343 | buf = 'Start\n^.\n ^Hello World\n' 344 | f = io.StringIO(buf) 345 | self.assertRaises(textfsm.TextFSMTemplateError, t._ParseFSMState, f) 346 | 347 | buf = ' Start\n ^.\n ^Hello World\n' 348 | f = io.StringIO(buf) 349 | self.assertRaises(textfsm.TextFSMTemplateError, t._ParseFSMState, f) 350 | 351 | # Multiple variables and substitution (depends on _ParseFSMVariables). 352 | buf = ( 353 | '# Headline\nStart\n ^.${Beer}${Wine}.\n' 354 | ' ^Hello $Beer\n ^Last-[Cc]ha$$nge\n' 355 | ) 356 | f = io.StringIO(buf) 357 | t.states = {} 358 | t._ParseFSMState(f) 359 | self.assertEqual(str(t.states['Start'][0]), ' ^.${Beer}${Wine}.') 360 | self.assertEqual(str(t.states['Start'][1]), ' ^Hello $Beer') 361 | self.assertEqual(str(t.states['Start'][2]), ' ^Last-[Cc]ha$$nge') 362 | try: 363 | _ = t.states['Start'][3] 364 | except IndexError: 365 | pass 366 | 367 | t.states['bogus'] = [] 368 | 369 | # State name too long (>32 char). 370 | buf = 'rnametoolong_nametoolong_nametoolong_nametoolong_nametoolo\n ^.\n\n' 371 | f = io.StringIO(buf) 372 | self.assertRaises(textfsm.TextFSMTemplateError, t._ParseFSMState, f) 373 | 374 | def testInvalidStates(self): 375 | 376 | # 'Continue' should not accept a destination. 377 | self.assertRaises( 378 | textfsm.TextFSMTemplateError, 379 | textfsm.TextFSMRule, 380 | '^.* -> Continue Start', 381 | ) 382 | 383 | # 'Error' accepts a text string but "next' state does not. 384 | self.assertEqual( 385 | str(textfsm.TextFSMRule(' ^ -> Error "hi there"')), 386 | ' ^ -> Error "hi there"', 387 | ) 388 | self.assertRaises( 389 | textfsm.TextFSMTemplateError, 390 | textfsm.TextFSMRule, 391 | '^.* -> Next "Hello World"', 392 | ) 393 | 394 | def testRuleStartsWithCarrot(self): 395 | 396 | f = io.StringIO( 397 | 'Value Beer (.)\nValue Wine (\\w)\n\nStart\n A Simple line' 398 | ) 399 | self.assertRaises(textfsm.TextFSMTemplateError, textfsm.TextFSM, f) 400 | 401 | def testValidateFSM(self): 402 | 403 | # No Values. 404 | f = io.StringIO('\nNotStart\n') 405 | self.assertRaises(textfsm.TextFSMTemplateError, textfsm.TextFSM, f) 406 | 407 | # No states. 408 | f = io.StringIO('Value unused (.)\n\n') 409 | self.assertRaises(textfsm.TextFSMTemplateError, textfsm.TextFSM, f) 410 | 411 | # No 'Start' state. 412 | f = io.StringIO('Value unused (.)\n\nNotStart\n') 413 | self.assertRaises(textfsm.TextFSMTemplateError, textfsm.TextFSM, f) 414 | 415 | # Has 'Start' state with valid destination 416 | f = io.StringIO('Value unused (.)\n\nStart\n') 417 | t = textfsm.TextFSM(f) 418 | t.states['Start'] = [] 419 | t.states['Start'].append(textfsm.TextFSMRule('^.* -> Start')) 420 | t._ValidateFSM() 421 | 422 | # Invalid destination. 423 | t.states['Start'].append(textfsm.TextFSMRule('^.* -> bogus')) 424 | self.assertRaises(textfsm.TextFSMTemplateError, t._ValidateFSM) 425 | 426 | # Now valid again. 427 | t.states['bogus'] = [] 428 | t.states['bogus'].append(textfsm.TextFSMRule('^.* -> Start')) 429 | t._ValidateFSM() 430 | 431 | # Valid destination with options. 432 | t.states['bogus'] = [] 433 | t.states['bogus'].append(textfsm.TextFSMRule('^.* -> Next.Record Start')) 434 | t._ValidateFSM() 435 | 436 | # Error with and without messages string. 437 | t.states['bogus'] = [] 438 | t.states['bogus'].append(textfsm.TextFSMRule('^.* -> Error')) 439 | t._ValidateFSM() 440 | t.states['bogus'].append(textfsm.TextFSMRule('^.* -> Error "Boo hoo"')) 441 | t._ValidateFSM() 442 | 443 | def testTextFSM(self): 444 | 445 | # Trivial template 446 | buf = 'Value Beer (.*)\n\nStart\n ^\\w\n' 447 | buf_result = buf 448 | f = io.StringIO(buf) 449 | t = textfsm.TextFSM(f) 450 | self.assertEqual(str(t), buf_result) 451 | 452 | # Slightly more complex, multple vars. 453 | buf = 'Value A (.*)\nValue B (.*)\n\nStart\n ^\\w\n\nState1\n ^.\n' 454 | buf_result = buf 455 | f = io.StringIO(buf) 456 | t = textfsm.TextFSM(f) 457 | self.assertEqual(str(t), buf_result) 458 | 459 | def testParseText(self): 460 | 461 | # Trivial FSM, no records produced. 462 | tplt = 'Value unused (.)\n\nStart\n ^Trivial SFM\n' 463 | t = textfsm.TextFSM(io.StringIO(tplt)) 464 | 465 | data = 'Non-matching text\nline1\nline 2\n' 466 | self.assertFalse(t.ParseText(data)) 467 | # Matching. 468 | data = 'Matching text\nTrivial SFM\nline 2\n' 469 | self.assertFalse(t.ParseText(data)) 470 | 471 | # Simple FSM, One Variable no options. 472 | tplt = 'Value boo (.*)\n\nStart\n ^$boo -> Next.Record\n\nEOF\n' 473 | t = textfsm.TextFSM(io.StringIO(tplt)) 474 | 475 | # Matching one line. 476 | # Tests 'Next' & 'Record' actions. 477 | data = 'Matching text' 478 | result = t.ParseText(data) 479 | self.assertListEqual(result, [['Matching text']]) 480 | 481 | # Matching two lines. Reseting FSM before Parsing. 482 | t.Reset() 483 | data = 'Matching text\nAnd again' 484 | result = t.ParseText(data) 485 | self.assertListEqual(result, [['Matching text'], ['And again']]) 486 | 487 | # Two Variables and singular options. 488 | tplt = ( 489 | 'Value Required boo (one)\nValue Filldown hoo (two)\n\n' 490 | 'Start\n ^$boo -> Next.Record\n ^$hoo -> Next.Record\n\n' 491 | 'EOF\n' 492 | ) 493 | t = textfsm.TextFSM(io.StringIO(tplt)) 494 | 495 | # Matching two lines. Only one records returned due to 'Required' flag. 496 | # Tests 'Filldown' and 'Required' options. 497 | data = 'two\none' 498 | result = t.ParseText(data) 499 | self.assertListEqual(result, [['one', 'two']]) 500 | 501 | t = textfsm.TextFSM(io.StringIO(tplt)) 502 | # Matching two lines. Two records returned due to 'Filldown' flag. 503 | data = 'two\none\none' 504 | t.Reset() 505 | result = t.ParseText(data) 506 | self.assertListEqual(result, [['one', 'two'], ['one', 'two']]) 507 | 508 | # Multiple Variables and options. 509 | tplt = ( 510 | 'Value Required,Filldown boo (one)\n' 511 | 'Value Filldown,Required hoo (two)\n\n' 512 | 'Start\n ^$boo -> Next.Record\n ^$hoo -> Next.Record\n\n' 513 | 'EOF\n' 514 | ) 515 | t = textfsm.TextFSM(io.StringIO(tplt)) 516 | data = 'two\none\none' 517 | result = t.ParseText(data) 518 | self.assertListEqual(result, [['one', 'two'], ['one', 'two']]) 519 | 520 | def testParseTextToDicts(self): 521 | 522 | # Trivial FSM, no records produced. 523 | tplt = 'Value unused (.)\n\nStart\n ^Trivial SFM\n' 524 | t = textfsm.TextFSM(io.StringIO(tplt)) 525 | 526 | data = 'Non-matching text\nline1\nline 2\n' 527 | self.assertFalse(t.ParseText(data)) 528 | # Matching. 529 | data = 'Matching text\nTrivial SFM\nline 2\n' 530 | self.assertFalse(t.ParseText(data)) 531 | 532 | # Simple FSM, One Variable no options. 533 | tplt = 'Value boo (.*)\n\nStart\n ^$boo -> Next.Record\n\nEOF\n' 534 | t = textfsm.TextFSM(io.StringIO(tplt)) 535 | 536 | # Matching one line. 537 | # Tests 'Next' & 'Record' actions. 538 | data = 'Matching text' 539 | result = t.ParseTextToDicts(data) 540 | self.assertListEqual(result, [{'boo': 'Matching text'}]) 541 | 542 | # Matching two lines. Reseting FSM before Parsing. 543 | t.Reset() 544 | data = 'Matching text\nAnd again' 545 | result = t.ParseTextToDicts(data) 546 | self.assertListEqual( 547 | result, [{'boo': 'Matching text'}, {'boo': 'And again'}] 548 | ) 549 | 550 | # Two Variables and singular options. 551 | tplt = ( 552 | 'Value Required boo (one)\nValue Filldown hoo (two)\n\n' 553 | 'Start\n ^$boo -> Next.Record\n ^$hoo -> Next.Record\n\n' 554 | 'EOF\n' 555 | ) 556 | t = textfsm.TextFSM(io.StringIO(tplt)) 557 | 558 | # Matching two lines. Only one records returned due to 'Required' flag. 559 | # Tests 'Filldown' and 'Required' options. 560 | data = 'two\none' 561 | result = t.ParseTextToDicts(data) 562 | self.assertListEqual(result, [{'hoo': 'two', 'boo': 'one'}]) 563 | 564 | t = textfsm.TextFSM(io.StringIO(tplt)) 565 | # Matching two lines. Two records returned due to 'Filldown' flag. 566 | data = 'two\none\none' 567 | t.Reset() 568 | result = t.ParseTextToDicts(data) 569 | self.assertListEqual( 570 | result, [{'hoo': 'two', 'boo': 'one'}, {'hoo': 'two', 'boo': 'one'}] 571 | ) 572 | 573 | # Multiple Variables and options. 574 | tplt = ( 575 | 'Value Required,Filldown boo (one)\n' 576 | 'Value Filldown,Required hoo (two)\n\n' 577 | 'Start\n ^$boo -> Next.Record\n ^$hoo -> Next.Record\n\n' 578 | 'EOF\n' 579 | ) 580 | t = textfsm.TextFSM(io.StringIO(tplt)) 581 | data = 'two\none\none' 582 | result = t.ParseTextToDicts(data) 583 | self.assertListEqual( 584 | result, [{'hoo': 'two', 'boo': 'one'}, {'hoo': 'two', 'boo': 'one'}] 585 | ) 586 | 587 | def testParseNullText(self): 588 | 589 | # Simple FSM, One Variable no options. 590 | tplt = 'Value boo (.*)\n\nStart\n ^$boo -> Next.Record\n\n' 591 | t = textfsm.TextFSM(io.StringIO(tplt)) 592 | 593 | # Null string 594 | data = '' 595 | result = t.ParseText(data) 596 | self.assertListEqual(result, []) 597 | 598 | def testReset(self): 599 | 600 | tplt = 'Value boo (.*)\n\nStart\n ^$boo -> Next.Record\n\nEOF\n' 601 | t = textfsm.TextFSM(io.StringIO(tplt)) 602 | data = 'Matching text' 603 | result1 = t.ParseText(data) 604 | t.Reset() 605 | result2 = t.ParseText(data) 606 | self.assertListEqual(result1, result2) 607 | 608 | tplt = ( 609 | 'Value boo (one)\nValue hoo (two)\n\n' 610 | 'Start\n ^$boo -> State1\n\n' 611 | 'State1\n ^$hoo -> Start\n\n' 612 | 'EOF' 613 | ) 614 | t = textfsm.TextFSM(io.StringIO(tplt)) 615 | 616 | data = 'one' 617 | t.ParseText(data) 618 | t.Reset() 619 | self.assertEqual(t._cur_state[0].match, '^$boo') 620 | self.assertIsNone(None, t._GetValue('boo').value) 621 | self.assertIsNone(t._GetValue('hoo').value) 622 | self.assertEqual(t._result, []) 623 | 624 | def testClear(self): 625 | 626 | # Clear Filldown variable. 627 | # Tests 'Clear'. 628 | tplt = ( 629 | 'Value Required boo (on.)\n' 630 | 'Value Filldown,Required hoo (tw.)\n\n' 631 | 'Start\n ^$boo -> Next.Record\n ^$hoo -> Next.Clear' 632 | ) 633 | 634 | t = textfsm.TextFSM(io.StringIO(tplt)) 635 | data = 'one\ntwo\nonE\ntwO' 636 | result = t.ParseText(data) 637 | self.assertListEqual(result, [['onE', 'two']]) 638 | 639 | # Clearall, with Filldown variable. 640 | # Tests 'Clearall'. 641 | tplt = ( 642 | 'Value Filldown boo (on.)\n' 643 | 'Value Filldown hoo (tw.)\n\n' 644 | 'Start\n ^$boo -> Next.Clearall\n' 645 | ' ^$hoo' 646 | ) 647 | 648 | t = textfsm.TextFSM(io.StringIO(tplt)) 649 | data = 'one\ntwo' 650 | result = t.ParseText(data) 651 | self.assertListEqual(result, [['', 'two']]) 652 | 653 | def testContinue(self): 654 | 655 | tplt = ( 656 | 'Value Required boo (on.)\n' 657 | 'Value Filldown,Required hoo (on.)\n\n' 658 | 'Start\n ^$boo -> Continue\n ^$hoo -> Continue.Record' 659 | ) 660 | 661 | t = textfsm.TextFSM(io.StringIO(tplt)) 662 | data = 'one\non0' 663 | result = t.ParseText(data) 664 | self.assertListEqual(result, [['one', 'one'], ['on0', 'on0']]) 665 | 666 | def testError(self): 667 | 668 | tplt = ( 669 | 'Value Required boo (on.)\n' 670 | 'Value Filldown,Required hoo (on.)\n\n' 671 | 'Start\n ^$boo -> Continue\n ^$hoo -> Error' 672 | ) 673 | 674 | t = textfsm.TextFSM(io.StringIO(tplt)) 675 | data = 'one' 676 | self.assertRaises(textfsm.TextFSMError, t.ParseText, data) 677 | 678 | tplt = ( 679 | 'Value Required boo (on.)\n' 680 | 'Value Filldown,Required hoo (on.)\n\n' 681 | 'Start\n ^$boo -> Continue\n ^$hoo -> Error "Hello World"' 682 | ) 683 | 684 | t = textfsm.TextFSM(io.StringIO(tplt)) 685 | self.assertRaises(textfsm.TextFSMError, t.ParseText, data) 686 | 687 | def testKey(self): 688 | tplt = ( 689 | 'Value Required boo (on.)\n' 690 | 'Value Required,Key hoo (on.)\n\n' 691 | 'Start\n ^$boo -> Continue\n ^$hoo -> Record' 692 | ) 693 | 694 | t = textfsm.TextFSM(io.StringIO(tplt)) 695 | self.assertIn('Key', t._GetValue('hoo').OptionNames()) 696 | self.assertNotIn('Key', t._GetValue('boo').OptionNames()) 697 | 698 | def testList(self): 699 | 700 | tplt = ( 701 | 'Value List boo (on.)\n' 702 | 'Value hoo (tw.)\n\n' 703 | 'Start\n ^$boo\n ^$hoo -> Next.Record\n\n' 704 | 'EOF' 705 | ) 706 | 707 | t = textfsm.TextFSM(io.StringIO(tplt)) 708 | data = 'one\ntwo\non0\ntw0' 709 | result = t.ParseText(data) 710 | self.assertListEqual(result, [[['one'], 'two'], [['on0'], 'tw0']]) 711 | 712 | tplt = ( 713 | 'Value List,Filldown boo (on.)\n' 714 | 'Value hoo (on.)\n\n' 715 | 'Start\n ^$boo -> Continue\n ^$hoo -> Next.Record\n\n' 716 | 'EOF' 717 | ) 718 | 719 | t = textfsm.TextFSM(io.StringIO(tplt)) 720 | data = 'one\non0\non1' 721 | result = t.ParseText(data) 722 | self.assertEqual( 723 | result, 724 | ([ 725 | [['one'], 'one'], 726 | [['one', 'on0'], 'on0'], 727 | [['one', 'on0', 'on1'], 'on1'], 728 | ]), 729 | ) 730 | 731 | tplt = ( 732 | 'Value List,Required boo (on.)\n' 733 | 'Value hoo (tw.)\n\n' 734 | 'Start\n ^$boo -> Continue\n ^$hoo -> Next.Record\n\n' 735 | 'EOF' 736 | ) 737 | 738 | t = textfsm.TextFSM(io.StringIO(tplt)) 739 | data = 'one\ntwo\ntw2' 740 | result = t.ParseText(data) 741 | self.assertListEqual(result, [[['one'], 'two']]) 742 | 743 | def testNestedMatching(self): 744 | """List-type values with nested regex capture groups are parsed correctly. 745 | 746 | Additionaly, another value is used with the same group-name as one of the 747 | nested groups to ensure that there are no conflicts when the same name is 748 | used. 749 | """ 750 | 751 | tplt = ( 752 | # A nested group is called "name" 753 | r'Value List foo ((?P\w+):\s+(?P\d+)\s+(?P\w{2})\s*)' 754 | '\n' 755 | # A regular value is called "name" 756 | r'Value name (\w+)' 757 | # "${name}" here refers to the Value called "name" 758 | '\n\nStart\n' 759 | r' ^\s*${foo}' 760 | '\n' 761 | r' ^\s*${name}' 762 | '\n' 763 | r' ^\s*$$ -> Record' 764 | ) 765 | t = textfsm.TextFSM(io.StringIO(tplt)) 766 | # Julia should be parsed as "name" separately 767 | data = ' Bob: 32 NC\n Alice: 27 NY\n Jeff: 45 CA\nJulia\n\n' 768 | result = t.ParseText(data) 769 | self.assertListEqual( 770 | result, 771 | ([[ 772 | [ 773 | {'name': 'Bob', 'age': '32', 'state': 'NC'}, 774 | {'name': 'Alice', 'age': '27', 'state': 'NY'}, 775 | {'name': 'Jeff', 'age': '45', 'state': 'CA'}, 776 | ], 777 | 'Julia', 778 | ]]), 779 | ) 780 | 781 | def testNestedNameConflict(self): 782 | tplt = ( 783 | # Two nested groups are called "name" 784 | r'Value List foo' 785 | r' ((?P\w+)\s+(?P\w+):\s+(?P\d+)\s+(?P\w{2})\s*)' 786 | '\nStart\n' 787 | r'^\s*${foo}' 788 | '\n ^' 789 | r'\s*$$ -> Record' 790 | ) 791 | self.assertRaises( 792 | textfsm.TextFSMTemplateError, textfsm.TextFSM, io.StringIO(tplt) 793 | ) 794 | 795 | def testGetValuesByAttrib(self): 796 | 797 | tplt = ( 798 | 'Value Required boo (on.)\n' 799 | 'Value Required,List hoo (on.)\n\n' 800 | 'Start\n ^$boo -> Continue\n ^$hoo -> Record' 801 | ) 802 | 803 | # Explicit default. 804 | t = textfsm.TextFSM(io.StringIO(tplt)) 805 | self.assertEqual(t.GetValuesByAttrib('List'), ['hoo']) 806 | self.assertEqual(t.GetValuesByAttrib('Filldown'), []) 807 | result = t.GetValuesByAttrib('Required') 808 | result.sort() 809 | self.assertListEqual(result, ['boo', 'hoo']) 810 | 811 | def testStateChange(self): 812 | 813 | # Sinple state change, no actions 814 | tplt = ( 815 | 'Value boo (one)\nValue hoo (two)\n\n' 816 | 'Start\n ^$boo -> State1\n\nState1\n ^$hoo -> Start\n\n' 817 | 'EOF' 818 | ) 819 | t = textfsm.TextFSM(io.StringIO(tplt)) 820 | 821 | data = 'one' 822 | t.ParseText(data) 823 | self.assertEqual(t._cur_state[0].match, '^$hoo') 824 | self.assertEqual('one', t._GetValue('boo').value) 825 | self.assertIsNone(t._GetValue('hoo').value) 826 | self.assertEqual(t._result, []) 827 | 828 | # State change with actions. 829 | tplt = ( 830 | 'Value boo (one)\nValue hoo (two)\n\n' 831 | 'Start\n ^$boo -> Next.Record State1\n\n' 832 | 'State1\n ^$hoo -> Start\n\n' 833 | 'EOF' 834 | ) 835 | t = textfsm.TextFSM(io.StringIO(tplt)) 836 | 837 | data = 'one' 838 | t.ParseText(data) 839 | self.assertEqual(t._cur_state[0].match, '^$hoo') 840 | self.assertIsNone(t._GetValue('boo').value) 841 | self.assertIsNone(t._GetValue('hoo').value) 842 | self.assertEqual(t._result, [['one', '']]) 843 | 844 | def testEOF(self): 845 | 846 | # Implicit EOF. 847 | tplt = 'Value boo (.*)\n\nStart\n ^$boo -> Next\n' 848 | t = textfsm.TextFSM(io.StringIO(tplt)) 849 | 850 | data = 'Matching text' 851 | result = t.ParseText(data) 852 | self.assertListEqual(result, [['Matching text']]) 853 | 854 | # EOF explicitly suppressed in template. 855 | tplt = 'Value boo (.*)\n\nStart\n ^$boo -> Next\n\nEOF\n' 856 | t = textfsm.TextFSM(io.StringIO(tplt)) 857 | 858 | result = t.ParseText(data) 859 | self.assertListEqual(result, []) 860 | 861 | # Implicit EOF suppressed by argument. 862 | tplt = 'Value boo (.*)\n\nStart\n ^$boo -> Next\n' 863 | t = textfsm.TextFSM(io.StringIO(tplt)) 864 | 865 | result = t.ParseText(data, eof=False) 866 | self.assertListEqual(result, []) 867 | 868 | def testEnd(self): 869 | 870 | # End State, EOF is skipped. 871 | tplt = 'Value boo (.*)\n\nStart\n ^$boo -> End\n ^$boo -> Record\n' 872 | t = textfsm.TextFSM(io.StringIO(tplt)) 873 | data = 'Matching text A\nMatching text B' 874 | 875 | result = t.ParseText(data) 876 | self.assertListEqual(result, []) 877 | 878 | # End State, with explicit Record. 879 | tplt = 'Value boo (.*)\n\nStart\n ^$boo -> Record End\n' 880 | t = textfsm.TextFSM(io.StringIO(tplt)) 881 | 882 | result = t.ParseText(data) 883 | self.assertListEqual(result, [['Matching text A']]) 884 | 885 | # EOF state transition is followed by implicit End State. 886 | tplt = 'Value boo (.*)\n\nStart\n ^$boo -> EOF\n ^$boo -> Record\n' 887 | t = textfsm.TextFSM(io.StringIO(tplt)) 888 | 889 | result = t.ParseText(data) 890 | self.assertListEqual(result, [['Matching text A']]) 891 | 892 | def testInvalidRegexp(self): 893 | 894 | tplt = 'Value boo (.$*)\n\nStart\n ^$boo -> Next\n' 895 | self.assertRaises( 896 | textfsm.TextFSMTemplateError, textfsm.TextFSM, io.StringIO(tplt) 897 | ) 898 | 899 | def testValidRegexp(self): 900 | """RegexObjects uncopyable in Python 2.6.""" 901 | 902 | tplt = 'Value boo (fo*)\n\nStart\n ^$boo -> Record\n' 903 | t = textfsm.TextFSM(io.StringIO(tplt)) 904 | data = 'f\nfo\nfoo\n' 905 | result = t.ParseText(data) 906 | self.assertListEqual(result, [['f'], ['fo'], ['foo']]) 907 | 908 | def testReEnteringState(self): 909 | """Issue 2. TextFSM should leave file pointer at top of template file.""" 910 | 911 | tplt = 'Value boo (.*)\n\nStart\n ^$boo -> Next Stop\n\nStop\n ^abc\n' 912 | output_text = 'one\ntwo' 913 | tmpl_file = io.StringIO(tplt) 914 | 915 | t = textfsm.TextFSM(tmpl_file) 916 | t.ParseText(output_text) 917 | t = textfsm.TextFSM(tmpl_file) 918 | t.ParseText(output_text) 919 | 920 | def testFillup(self): 921 | """Fillup should work ok.""" 922 | tplt = """Value Required Col1 ([^-]+) 923 | Value Fillup Col2 ([^-]+) 924 | Value Fillup Col3 ([^-]+) 925 | 926 | Start 927 | ^$Col1 -- -- -> Record 928 | ^$Col1 $Col2 -- -> Record 929 | ^$Col1 -- $Col3 -> Record 930 | ^$Col1 $Col2 $Col3 -> Record 931 | """ 932 | data = """ 933 | 1 -- B1 934 | 2 A2 -- 935 | 3 -- B3 936 | """ 937 | t = textfsm.TextFSM(io.StringIO(tplt)) 938 | result = t.ParseText(data) 939 | self.assertListEqual( 940 | result, [['1', 'A2', 'B1'], ['2', 'A2', 'B3'], ['3', '', 'B3']] 941 | ) 942 | 943 | 944 | class UnitTestUnicode(unittest.TestCase): 945 | """Tests the FSM engine.""" 946 | 947 | def testFSMValue(self): 948 | # Check basic line is parsed. 949 | line = 'Value beer (\\S+Δ)' 950 | v = textfsm.TextFSMValue() 951 | v.Parse(line) 952 | self.assertEqual(v.name, 'beer') 953 | self.assertEqual(v.regex, '(\\S+Δ)') 954 | self.assertEqual(v.template, '(?P\\S+Δ)') 955 | self.assertFalse(v.options) 956 | 957 | def testFSMRule(self): 958 | # Basic line, no action 959 | line = ' ^A beer called ${beer}Δ' 960 | r = textfsm.TextFSMRule(line) 961 | self.assertEqual(r.match, '^A beer called ${beer}Δ') 962 | self.assertEqual(r.line_op, '') 963 | self.assertEqual(r.new_state, '') 964 | self.assertEqual(r.record_op, '') 965 | 966 | def testTemplateValue(self): 967 | # Complex template, multiple vars and states with comments (no var options). 968 | buf = """# Header 969 | # Header 2 970 | Value Beer (.*) 971 | Value Wine (\\w+) 972 | 973 | # An explanation with a unicode character Δ 974 | Start 975 | ^hi there ${Wine}. -> Next.Record State1 976 | 977 | State1 978 | ^\\wΔ 979 | ^$Beer .. -> Start 980 | # Some comments 981 | ^$$ -> Next 982 | ^$$ -> End 983 | 984 | End 985 | # Tail comment. 986 | """ 987 | 988 | buf_result = """Value Beer (.*) 989 | Value Wine (\\w+) 990 | 991 | Start 992 | ^hi there ${Wine}. -> Next.Record State1 993 | 994 | State1 995 | ^\\wΔ 996 | ^$Beer .. -> Start 997 | ^$$ -> Next 998 | ^$$ -> End 999 | """ 1000 | f = io.StringIO(buf) 1001 | t = textfsm.TextFSM(f) 1002 | self.assertEqual(str(t), buf_result) 1003 | 1004 | 1005 | if __name__ == '__main__': 1006 | unittest.main() 1007 | -------------------------------------------------------------------------------- /tests/texttable_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | # Copyright 2012 Google Inc. All Rights Reserved. 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 14 | # implied. See the License for the specific language governing 15 | # permissions and limitations under the License. 16 | 17 | """Unittest for text table.""" 18 | 19 | import io 20 | import unittest 21 | from textfsm import terminal 22 | from textfsm import texttable 23 | 24 | 25 | def cmp(a, b): 26 | return (a > b) - (a < b) 27 | 28 | 29 | class UnitTestRow(unittest.TestCase): 30 | """Tests texttable.Row() class.""" 31 | 32 | def setUp(self): 33 | super(UnitTestRow, self).setUp() 34 | self.row = texttable.Row() 35 | self.row._keys = ['a', 'b', 'c'] 36 | self.row._values = ['1', '2', '3'] 37 | self.row._BuildIndex() 38 | 39 | def testRowBasicMethods(self): 40 | row = texttable.Row() 41 | # Setting columns (__setitem__). 42 | row['a'] = 'one' 43 | row['b'] = 'two' 44 | row['c'] = 'three' 45 | 46 | # Access a single column (__getitem__). 47 | self.assertEqual('one', row['a']) 48 | self.assertEqual('two', row['b']) 49 | self.assertEqual('three', row['c']) 50 | 51 | # Access multiple columns (__getitem__). 52 | self.assertEqual(['one', 'three'], row[('a', 'c')]) 53 | self.assertEqual(['two', 'three'], row[('b', 'c')]) 54 | 55 | # Access integer indexes (__getitem__). 56 | self.assertEqual('one', row[0]) 57 | self.assertEqual(['two', 'three'], row[1:]) 58 | 59 | # Test "get". 60 | self.assertEqual('one', row.get('a')) 61 | self.assertEqual('one', row.get('a', 'four')) 62 | self.assertEqual('four', row.get('d', 'four')) 63 | self.assertIsNone(row.get('d')) 64 | 65 | self.assertEqual(['one', 'three'], row.get(('a', 'c'), 'four')) 66 | self.assertEqual(['one', 'four'], row.get(('a', 'd'), 'four')) 67 | self.assertEqual(['one', None], row.get(('a', 'd'))) 68 | 69 | self.assertEqual('one', row.get(0, 'four')) 70 | self.assertEqual('four', row.get(3, 'four')) 71 | self.assertIsNone(row.get(3)) 72 | 73 | # Change existing column value. 74 | row['b'] = 'Two' 75 | self.assertEqual('Two', row['b']) 76 | 77 | # Length. 78 | self.assertEqual(3, len(row)) 79 | 80 | # Contains. 81 | self.assertNotIn('two', row) 82 | self.assertIn('Two', row) 83 | 84 | # Iteration. 85 | self.assertEqual(['one', 'Two', 'three'], list(row)) 86 | 87 | def testRowPublicMethods(self): 88 | self.row.header = ('x', 'y', 'z') 89 | # Header should be set, values initialised to None. 90 | self.assertEqual(['x', 'y', 'z'], self.row.header) 91 | self.assertEqual(['1', '2', '3'], self.row.values) 92 | row = texttable.Row() 93 | row.header = ('x', 'y', 'z') 94 | self.assertEqual(['x', 'y', 'z'], row.header) 95 | self.assertEqual([None, None, None], row.values) 96 | 97 | def testSetValues(self): 98 | """Tests setting row values from 'From' method.""" 99 | 100 | # Set values from Dict. 101 | self.row._SetValues({'a': 'seven', 'b': 'eight', 'c': 'nine'}) 102 | self.assertEqual(['seven', 'eight', 'nine'], self.row._values) 103 | self.row._SetValues({'b': '8', 'a': '7', 'c': '9'}) 104 | self.assertEqual(['7', '8', '9'], self.row._values) 105 | 106 | # Converts integers to string equivalents. 107 | # Excess key/value pairs are ignored. 108 | self.row._SetValues({'a': 1, 'b': 2, 'c': 3, 'd': 4}) 109 | self.assertEqual(['1', '2', '3'], self.row._values) 110 | 111 | # Values can come from a list of equal length the the keys. 112 | self.row._SetValues((7, '8', 9)) 113 | self.assertEqual(['7', '8', '9'], self.row._values) 114 | 115 | # Or from a tuple of the same length. 116 | self.row._SetValues(('vb', 'coopers', 'squires')) 117 | self.assertEqual(['vb', 'coopers', 'squires'], self.row._values) 118 | 119 | # Raise error if list length is incorrect. 120 | self.assertRaises(TypeError, self.row._SetValues, 121 | ['seven', 'eight', 'nine', 'ten']) 122 | # Raise error if row object has mismatched header. 123 | row = texttable.Row() 124 | self.row._keys = ['a'] 125 | self.row._values = ['1'] 126 | self.assertRaises(TypeError, self.row._SetValues, row) 127 | # Raise error if assigning wrong data type. 128 | self.assertRaises(TypeError, row._SetValues, 'abc') 129 | 130 | def testHeader(self): 131 | """Tests value property.""" 132 | self.row.header = ('x', 'y', 'z') 133 | self.assertEqual(['x', 'y', 'z'], self.row.header) 134 | self.assertRaises(ValueError, self.row._SetHeader, ('a', 'b', 'c', 'd')) 135 | 136 | def testValue(self): 137 | """Tests value property.""" 138 | self.row.values = {'a': 'seven', 'b': 'eight', 'c': 'nine'} 139 | self.assertEqual(['seven', 'eight', 'nine'], self.row.values) 140 | self.row.values = (7, '8', 9) 141 | self.assertEqual(['7', '8', '9'], self.row.values) 142 | 143 | def testIndex(self): 144 | """Tests Insert and Index methods.""" 145 | 146 | self.assertEqual(1, self.row.index('b')) 147 | self.assertRaises(ValueError, self.row.index, 'bogus') 148 | 149 | # Insert element within row. 150 | self.row.Insert('black', 'white', 1) 151 | self.row.Insert('red', 'yellow', -1) 152 | self.assertEqual(['a', 'black', 'b', 'red', 'c'], self.row.header) 153 | self.assertEqual(['1', 'white', '2', 'yellow', '3'], self.row.values) 154 | self.assertEqual(1, self.row.index('black')) 155 | self.assertEqual(2, self.row.index('b')) 156 | self.assertRaises(IndexError, self.row.Insert, 'grey', 'gray', 6) 157 | self.assertRaises(IndexError, self.row.Insert, 'grey', 'gray', -7) 158 | 159 | 160 | class MyRow(texttable.Row): 161 | pass 162 | 163 | 164 | class UnitTestTextTable(unittest.TestCase): 165 | 166 | # pylint: disable=invalid-name 167 | def BasicTable(self): 168 | t = texttable.TextTable() 169 | t.header = ('a', 'b', 'c') 170 | t.Append(('1', '2', '3')) 171 | t.Append(('10', '20', '30')) 172 | return t 173 | 174 | def testFilter(self): 175 | old_table = self.BasicTable() 176 | filtered_table = old_table.Filter( 177 | function=lambda row: row['a'] == '10') 178 | self.assertEqual(1, filtered_table.size) 179 | 180 | def testFilterNone(self): 181 | t = texttable.TextTable() 182 | t.header = ('a', 'b', 'c') 183 | t.Append(('', '', [])) 184 | filtered_table = t.Filter() 185 | self.assertEqual(0, filtered_table.size) 186 | 187 | def testMap(self): 188 | old_table = self.BasicTable() 189 | filtered_table = old_table.Map( 190 | function=lambda row: row['a'] == '10' and row) 191 | self.assertEqual(1, filtered_table.size) 192 | 193 | def testCustomRow(self): 194 | table = texttable.TextTable() 195 | table.header = ('a', 'b', 'c') 196 | self.assertEqual(type(texttable.Row()), type(table[0])) 197 | table = texttable.TextTable(row_class=MyRow) 198 | self.assertEqual(MyRow, table.row_class) 199 | table.header = ('a', 'b', 'c') 200 | self.assertEqual(type(MyRow()), type(table[0])) 201 | 202 | def testTableRepr(self): 203 | self.assertEqual( 204 | "TextTable('a, b, c\\n1, 2, 3\\n10, 20, 30\\n')", 205 | repr(self.BasicTable())) 206 | 207 | def testTableStr(self): 208 | self.assertEqual('a, b, c\n1, 2, 3\n10, 20, 30\n', 209 | self.BasicTable().__str__()) 210 | 211 | def testTableSetRow(self): 212 | t = self.BasicTable() 213 | t.Append(('one', 'two', 'three')) 214 | self.assertEqual(['one', 'two', 'three'], t[3].values) 215 | self.assertEqual(3, t.size) 216 | 217 | def testTableRowTypes(self): 218 | t = self.BasicTable() 219 | t.Append(('one', ['two', None], None)) 220 | self.assertEqual(['one', ['two', 'None'], 'None'], t[3].values) 221 | self.assertEqual(3, t.size) 222 | 223 | def testTableRowDictWithInt(self): 224 | t = self.BasicTable() 225 | t.Append({'a': 1, 'b': 'two', 'c': 3}) 226 | self.assertEqual(['1', 'two', '3'], t[3].values) 227 | self.assertEqual(3, t.size) 228 | 229 | def testTableRowListWithInt(self): 230 | t = self.BasicTable() 231 | t.Append([1, 'two', 3]) 232 | self.assertEqual(['1', 'two', '3'], t[3].values) 233 | self.assertEqual(3, t.size) 234 | 235 | def testTableGetRow(self): 236 | t = self.BasicTable() 237 | self.assertEqual(['1', '2', '3'], t[1].values) 238 | self.assertEqual(['1', '3'], t[1][('a', 'c')]) 239 | self.assertEqual('3', t[1][('c')]) 240 | for rnum in range(t.size): 241 | self.assertEqual(rnum, t[rnum].row) 242 | 243 | def testTableRowWith(self): 244 | t = self.BasicTable() 245 | self.assertEqual(t.RowWith('a', '10'), t[2]) 246 | self.assertRaises(IndexError, t.RowWith, 'g', '5') 247 | 248 | def testContains(self): 249 | t = self.BasicTable() 250 | self.assertIn('a', t) 251 | self.assertNotIn('x', t) 252 | 253 | def testIteration(self): 254 | t = self.BasicTable() 255 | index = 0 256 | for r in t: 257 | index += 1 258 | self.assertEqual(r, t[index]) 259 | self.assertEqual(index, r.table._iterator) 260 | 261 | # Have we iterated over all entries. 262 | self.assertEqual(index, t.size) 263 | # The iterator count is reset. 264 | self.assertEqual(0, t._iterator) 265 | 266 | # Can we iterate repeatedly. 267 | index = 0 268 | index2 = 0 269 | for r in t: 270 | index += 1 271 | self.assertEqual(r, t[index]) 272 | 273 | index1 = 0 274 | try: 275 | for r in t: 276 | index1 += 1 277 | index2 = 0 278 | self.assertEqual(index1, r.table._iterator) 279 | # Test nesting of iterations. 280 | for r2 in t: 281 | index2 += 1 282 | self.assertEqual(index2, r2.table._iterator) 283 | # Preservation of outer iterator after 'break'. 284 | if index1 == 2 and index2 == 2: 285 | break 286 | if index1 == 2: 287 | # Restoration of initial iterator after exception. 288 | raise IndexError 289 | self.assertEqual(index1, r.table._iterator) 290 | except IndexError: 291 | pass 292 | 293 | # Have we iterated over all entries - twice. 294 | self.assertEqual(index, t.size) 295 | self.assertEqual(index2, t.size) 296 | # The iterator count is reset. 297 | self.assertEqual(0, t._iterator) 298 | 299 | def testCsvToTable(self): 300 | buf = """ 301 | # A comment 302 | a,b, c, d # Trim comment 303 | # Inline comment 304 | # 1,2,3,4 305 | 1,2,3,4 306 | 5, 6, 7, 8 307 | 10, 11 308 | # More comments. 309 | """ 310 | f = io.StringIO(buf) 311 | t = texttable.TextTable() 312 | self.assertEqual(2, t.CsvToTable(f)) 313 | # pylint: disable=E1101 314 | self.assertEqual(['a', 'b', 'c', 'd'], t.header.values) 315 | self.assertEqual(['1', '2', '3', '4'], t[1].values) 316 | self.assertEqual(['5', '6', '7', '8'], t[2].values) 317 | self.assertEqual(2, t.size) 318 | 319 | def testHeaderIndex(self): 320 | t = self.BasicTable() 321 | self.assertEqual('c', t.header[2]) 322 | self.assertEqual('a', t.header[0]) 323 | 324 | def testAppend(self): 325 | t = self.BasicTable() 326 | t.Append(['10', '20', '30']) 327 | self.assertEqual(3, t.size) 328 | self.assertEqual(['10', '20', '30'], t[3].values) 329 | 330 | t.Append(('100', '200', '300')) 331 | self.assertEqual(4, t.size) 332 | self.assertEqual(['100', '200', '300'], t[4].values) 333 | 334 | t.Append(t[1]) 335 | self.assertEqual(5, t.size) 336 | self.assertEqual(['1', '2', '3'], t[5].values) 337 | 338 | t.Append({'a': '11', 'b': '12', 'c': '13'}) 339 | self.assertEqual(6, t.size) 340 | self.assertEqual(['11', '12', '13'], t[6].values) 341 | 342 | # The row index and container table should be set on new rows. 343 | self.assertEqual(6, t[6].row) 344 | self.assertEqual(t[1].table, t[6].table) 345 | 346 | self.assertRaises(TypeError, t.Append, ['20', '30']) 347 | self.assertRaises(TypeError, t.Append, ('1', '2', '3', '4')) 348 | self.assertRaises(TypeError, t.Append, 349 | {'a': '11', 'b': '12', 'd': '13'}) 350 | 351 | def testDeleteRow(self): 352 | t = self.BasicTable() 353 | self.assertEqual(2, t.size) 354 | t.Remove(1) 355 | self.assertEqual(['10', '20', '30'], t[1].values) 356 | for row in t: 357 | self.assertEqual(row, t[row.row]) 358 | t.Remove(1) 359 | self.assertFalse(t.size) 360 | 361 | def testRowNumberandParent(self): 362 | t = self.BasicTable() 363 | t.Append(['10', '20', '30']) 364 | t.Remove(1) 365 | for rownum, row in enumerate(t, start=1): 366 | self.assertEqual(row.row, rownum) 367 | self.assertEqual(row.table, t) 368 | t2 = self.BasicTable() 369 | t.table = t2 370 | for rownum, row in enumerate(t, start=1): 371 | self.assertEqual(row.row, rownum) 372 | self.assertEqual(row.table, t) 373 | 374 | def testAddColumn(self): 375 | t = self.BasicTable() 376 | t.AddColumn('Beer') 377 | # pylint: disable=E1101 378 | self.assertEqual(['a', 'b', 'c', 'Beer'], t.header.values) 379 | self.assertEqual(['10', '20', '30', ''], t[2].values) 380 | 381 | t.AddColumn('Wine', default='Merlot', col_index=1) 382 | self.assertEqual(['a', 'Wine', 'b', 'c', 'Beer'], t.header.values) 383 | self.assertEqual(['10', 'Merlot', '20', '30', ''], t[2].values) 384 | 385 | t.AddColumn('Spirits', col_index=-2) 386 | self.assertEqual(['a', 'Wine', 'b', 'Spirits', 'c', 'Beer'], 387 | t.header.values) 388 | self.assertEqual(['10', 'Merlot', '20', '', '30', ''], t[2].values) 389 | 390 | self.assertRaises(IndexError, t.AddColumn, 'x', col_index=6) 391 | self.assertRaises(IndexError, t.AddColumn, 'x', col_index=-7) 392 | self.assertRaises(texttable.TableError, t.AddColumn, 'b') 393 | 394 | def testAddTable(self): 395 | t = self.BasicTable() 396 | t2 = self.BasicTable() 397 | t3 = t + t2 398 | # pylint: disable=E1101 399 | self.assertEqual(['a', 'b', 'c'], t3.header.values) 400 | self.assertEqual(['10', '20', '30'], t3[2].values) 401 | self.assertEqual(['10', '20', '30'], t3[4].values) 402 | self.assertEqual(4, t3.size) 403 | 404 | def testExtendTable(self): 405 | t2 = self.BasicTable() 406 | t2.AddColumn('Beer') 407 | t2[1]['Beer'] = 'Lager' 408 | t2[1]['three'] = 'three' 409 | t2.Append(('one', 'two', 'three', 'Stout')) 410 | 411 | t = self.BasicTable() 412 | # Explicit key, use first column. 413 | t.extend(t2, ('a',)) 414 | # pylint: disable=E1101 415 | self.assertEqual(['a', 'b', 'c', 'Beer'], t.header.values) 416 | # Only new columns have updated values. 417 | self.assertEqual(['1', '2', '3', 'Lager'], t[1].values) 418 | # All rows are extended. 419 | self.assertEqual(['10', '20', '30', ''], t[2].values) 420 | # The third row of 't2', is not included as there is no matching 421 | # row with the same key in the first table 't'. 422 | self.assertEqual(2, t.size) 423 | 424 | # pylint: disable=E1101 425 | t = self.BasicTable() 426 | # If a Key is non-unique (which is a soft-error), then the first instance 427 | # on the RHS is used for and applied to all non-unique entries on the LHS. 428 | t.Append(('1', '2b', '3b')) 429 | t2.Append(('1', 'two', '', 'Ale')) 430 | t.extend(t2, ('a',)) 431 | self.assertEqual(['1', '2', '3', 'Lager'], t[1].values) 432 | self.assertEqual(['1', '2b', '3b', 'Lager'], t[3].values) 433 | 434 | t = self.BasicTable() 435 | # No explicit key, row number is used as the key. 436 | t.extend(t2) 437 | self.assertEqual(['a', 'b', 'c', 'Beer'], t.header.values) 438 | # Since row is key we pick up new values from corresponding row number. 439 | self.assertEqual(['1', '2', '3', 'Lager'], t[1].values) 440 | # All rows are still extended. 441 | self.assertEqual(['10', '20', '30', ''], t[2].values) 442 | # The third/fourth row of 't2', is not included as there is no corresponding 443 | # row in the first table 't'. 444 | self.assertEqual(2, t.size) 445 | 446 | t = self.BasicTable() 447 | t.Append(('1', 'two', '3')) 448 | t.Append(('two', '1', 'three')) 449 | t2 = texttable.TextTable() 450 | t2.header = ('a', 'b', 'c', 'Beer') 451 | t2.Append(('1', 'two', 'three', 'Stout')) 452 | # Explicitly declare which columns constitute the key. 453 | # Sometimes more than one row is needed to define a unique key (superkey). 454 | t.extend(t2, ('a', 'b')) 455 | 456 | self.assertEqual(['a', 'b', 'c', 'Beer'], t.header.values) 457 | # key '1', '2' does not equal '1', 'two', so column unmatched. 458 | self.assertEqual(['1', '2', '3', ''], t[1].values) 459 | # '1', 'two' matches but 'two', '1' does not as order is important. 460 | self.assertEqual(['1', 'two', '3', 'Stout'], t[3].values) 461 | self.assertEqual(['two', '1', 'three', ''], t[4].values) 462 | self.assertEqual(4, t.size) 463 | 464 | # Expects a texttable as the argument. 465 | self.assertRaises(AttributeError, t.extend, ['a', 'list']) 466 | # All Key column Names must be valid. 467 | self.assertRaises(IndexError, t.extend, ['a', 'list'], ('a', 'bogus')) 468 | 469 | def testTableWithLabels(self): 470 | t = self.BasicTable() 471 | self.assertEqual( 472 | '# LABEL a\n1.b 2\n1.c 3\n10.b 20\n10.c 30\n', 473 | t.LabelValueTable()) 474 | self.assertEqual( 475 | '# LABEL a\n1.b 2\n1.c 3\n10.b 20\n10.c 30\n', 476 | t.LabelValueTable(['a'])) 477 | self.assertEqual( 478 | '# LABEL a.c\n1.3.b 2\n10.30.b 20\n', 479 | t.LabelValueTable(['a', 'c'])) 480 | self.assertEqual( 481 | '# LABEL a.c\n1.3.b 2\n10.30.b 20\n', 482 | t.LabelValueTable(['c', 'a'])) 483 | self.assertRaises(texttable.TableError, t.LabelValueTable, ['a', 'z']) 484 | 485 | def testTextJustify(self): 486 | t = texttable.TextTable() 487 | self.assertEqual([' a '], t._TextJustify('a', 6)) 488 | self.assertEqual([' a b '], t._TextJustify('a b', 6)) 489 | self.assertEqual([' a b '], t._TextJustify('a b', 6)) 490 | self.assertEqual([' a ', ' b '], t._TextJustify('a b', 3)) 491 | self.assertEqual([' a ', ' b '], t._TextJustify('a b', 3)) 492 | self.assertRaises(texttable.TableError, t._TextJustify, 'a', 2) 493 | self.assertRaises(texttable.TableError, t._TextJustify, 'a bb', 3) 494 | self.assertEqual([' a b '], t._TextJustify('a\tb', 6)) 495 | self.assertEqual([' a b '], t._TextJustify('a\t\tb', 6)) 496 | self.assertEqual([' a ', ' b '], t._TextJustify('a\nb\t', 6)) 497 | 498 | def testSmallestColSize(self): 499 | t = texttable.TextTable() 500 | self.assertEqual(1, t._SmallestColSize('a')) 501 | self.assertEqual(2, t._SmallestColSize('a bb')) 502 | self.assertEqual(4, t._SmallestColSize('a cccc bb')) 503 | self.assertEqual(0, t._SmallestColSize('')) 504 | self.assertEqual(1, t._SmallestColSize('a\tb')) 505 | self.assertEqual(1, t._SmallestColSize('a\nb\tc')) 506 | self.assertEqual(3, t._SmallestColSize('a\nbbb\n\nc')) 507 | # Check if _SmallestColSize is not influenced by ANSI colors. 508 | self.assertEqual( 509 | 3, t._SmallestColSize('bbb ' + terminal.AnsiText('bb', ['red']))) 510 | 511 | def testFormattedTableColor(self): 512 | # Test to specify the color defined in terminal.FG_COLOR_WORDS 513 | t = texttable.TextTable() 514 | t.header = ('LSP', 'Name') 515 | t.Append(('col1', 'col2')) 516 | for color_key in terminal.FG_COLOR_WORDS: 517 | t[0].color = terminal.FG_COLOR_WORDS[color_key] 518 | t.FormattedTable() 519 | self.assertEqual(sorted(t[0].color), 520 | sorted(terminal.FG_COLOR_WORDS[color_key])) 521 | for color_key in terminal.BG_COLOR_WORDS: 522 | t[0].color = terminal.BG_COLOR_WORDS[color_key] 523 | t.FormattedTable() 524 | self.assertEqual(sorted(t[0].color), 525 | sorted(terminal.BG_COLOR_WORDS[color_key])) 526 | 527 | def testFormattedTableColoredMultilineCells(self): 528 | t = texttable.TextTable() 529 | t.header = ('LSP', 'Name') 530 | t.Append((terminal.AnsiText('col1 boembabies', ['yellow']), 'col2')) 531 | t.Append(('col1', 'col2')) 532 | self.assertEqual( 533 | ' LSP Name \n' 534 | '====================\n' 535 | ' \033[33mcol1 col2 \n' 536 | ' boembabies\033[0m \n' 537 | '--------------------\n' 538 | ' col1 col2 \n', 539 | t.FormattedTable(width=20)) 540 | 541 | def testFormattedTableColoredCells(self): 542 | t = texttable.TextTable() 543 | t.header = ('LSP', 'Name') 544 | t.Append((terminal.AnsiText('col1', ['yellow']), 'col2')) 545 | t.Append(('col1', 'col2')) 546 | self.assertEqual( 547 | ' LSP Name \n' 548 | '============\n' 549 | ' \033[33mcol1\033[0m col2 \n' 550 | ' col1 col2 \n', 551 | t.FormattedTable()) 552 | 553 | def testFormattedTableColoredHeaders(self): 554 | t = texttable.TextTable() 555 | t.header = (terminal.AnsiText('LSP', ['yellow']), 'Name') 556 | t.Append(('col1', 'col2')) 557 | t.Append(('col1', 'col2')) 558 | self.assertEqual( 559 | ' \033[33mLSP\033[0m Name \n' 560 | '============\n' 561 | ' col1 col2 \n' 562 | ' col1 col2 \n', 563 | t.FormattedTable()) 564 | 565 | self.assertEqual( 566 | ' col1 col2 \n' 567 | ' col1 col2 \n', 568 | t.FormattedTable(display_header=False)) 569 | 570 | def testFormattedTable(self): 571 | # Basic table has a single whitespace on each side of the max cell width. 572 | t = self.BasicTable() 573 | self.assertEqual( 574 | ' a b c \n' 575 | '============\n' 576 | ' 1 2 3 \n' 577 | ' 10 20 30 \n', 578 | t.FormattedTable()) 579 | 580 | # An increase in a cell size (or header), increases the side of that column. 581 | t.AddColumn('Beer') 582 | self.assertEqual( 583 | ' a b c Beer \n' 584 | '==================\n' 585 | ' 1 2 3 \n' 586 | ' 10 20 30 \n', 587 | t.FormattedTable()) 588 | 589 | self.assertEqual( 590 | ' 1 2 3 \n' 591 | ' 10 20 30 \n', 592 | t.FormattedTable(display_header=False)) 593 | 594 | # Multiple words are on one line while space permits. 595 | t.Remove(1) 596 | t.Append(('', '', '', 'James Squire')) 597 | self.assertEqual( 598 | ' a b c Beer \n' 599 | '==========================\n' 600 | ' 10 20 30 \n' 601 | ' James Squire \n', 602 | t.FormattedTable()) 603 | 604 | # Or split across rows if not enough space. 605 | # A '---' divider is inserted to give a delimiter for multiline data. 606 | self.assertEqual( 607 | ' a b c Beer \n' 608 | '====================\n' 609 | ' 10 20 30 \n' 610 | '--------------------\n' 611 | ' James \n' 612 | ' Squire \n', 613 | t.FormattedTable(20)) 614 | 615 | # Not needed below the data if last line, is needed otherwise. 616 | t.Append(('1', '2', '3', '4')) 617 | self.assertEqual( 618 | ' a b c Beer \n' 619 | '====================\n' 620 | ' 10 20 30 \n' 621 | '--------------------\n' 622 | ' James \n' 623 | ' Squire \n' 624 | '--------------------\n' 625 | ' 1 2 3 4 \n', 626 | t.FormattedTable(20)) 627 | 628 | # Multiple multi line columms. 629 | t.Remove(3) 630 | t.Append(('', 'A small essay with a longword here', '1', '2')) 631 | self.assertEqual( 632 | ' a b c Beer \n' 633 | '==========================\n' 634 | ' 10 20 30 \n' 635 | '--------------------------\n' 636 | ' James \n' 637 | ' Squire \n' 638 | '--------------------------\n' 639 | ' A small 1 2 \n' 640 | ' essay \n' 641 | ' with a \n' 642 | ' longword \n' 643 | ' here \n', 644 | t.FormattedTable(26)) 645 | 646 | # Available space is added to multiline columns proportionaly 647 | # i.e. a column with twice as much text gets twice the space. 648 | self.assertEqual( 649 | ' a b c Beer \n' 650 | '=============================\n' 651 | ' 10 20 30 \n' 652 | '-----------------------------\n' 653 | ' James \n' 654 | ' Squire \n' 655 | '-----------------------------\n' 656 | ' A small 1 2 \n' 657 | ' essay with \n' 658 | ' a longword \n' 659 | ' here \n', 660 | t.FormattedTable(29)) 661 | 662 | # Display fails if the minimum size needed is not available. 663 | # These are both 1-char less than the minimum required. 664 | self.assertRaises(texttable.TableError, t.FormattedTable, 25) 665 | t.Remove(3) 666 | t.Remove(2) 667 | self.assertRaises(texttable.TableError, t.FormattedTable, 17) 668 | t.Append(('line\nwith\n\nbreaks', 'Line with\ttabs\t\t', 669 | 'line with lots of spaces.', '4')) 670 | t[0].color = ['yellow'] 671 | self.assertEqual( 672 | '\033[33m a b c Beer \n' 673 | '==============================\033[0m\n' 674 | ' 10 20 30 \n' 675 | '------------------------------\n' 676 | ' line Line line 4 \n' 677 | ' with with with \n' 678 | ' tabs lots of \n' 679 | ' breaks spaces. \n', 680 | t.FormattedTable(30)) 681 | 682 | t[0].color = None 683 | self.assertEqual( 684 | ' a b c Beer \n' 685 | '========================================\n' 686 | ' 10 20 30 \n' 687 | '----------------------------------------\n' 688 | ' line Line line with 4 \n' 689 | ' with with lots of \n' 690 | ' tabs spaces. \n' 691 | ' breaks \n', 692 | t.FormattedTable(40)) 693 | 694 | def testFormattedTable2(self): 695 | t = texttable.TextTable() 696 | t.header = ('Host', 'Interface', 'Admin', 'Oper', 'Proto', 'Address') 697 | t.Append(('DeviceA', 'lo0', 'up', 'up', '', [])) 698 | t.Append(('DeviceA', 'lo0.0', 'up', 'up', 'inet', 699 | ['127.0.0.1', '10.100.100.1'])) 700 | t.Append(('DeviceA', 'lo0.16384', 'up', 'up', 'inet', ['127.0.0.1'])) 701 | t[-2].color = ['red'] 702 | 703 | # pylint: disable=C6310 704 | self.assertEqual( 705 | ' Host Interface Admin Oper Proto Address \n' 706 | '==============================================================\n' 707 | ' DeviceA lo0 up up \n' 708 | '--------------------------------------------------------------\n' 709 | '\033[31m DeviceA lo0.0 up up inet 127.0.0.1, \n' 710 | ' 10.100.100.1 \033[0m\n' 711 | '--------------------------------------------------------------\n' 712 | ' DeviceA lo0.16384 up up inet 127.0.0.1 \n', 713 | t.FormattedTable(62)) 714 | 715 | # Test with specific columns only 716 | self.assertEqual( 717 | ' Host Interface Admin Oper Address \n' 718 | '==========================================================\n' 719 | ' DeviceA lo0 up up \n' 720 | '\033[31m DeviceA lo0.0 up up 127.0.0.1, 10.100.100.1 \033[0m\n' 721 | ' DeviceA lo0.16384 up up 127.0.0.1 \n', 722 | t.FormattedTable(62, columns=['Host', 'Interface', 'Admin', 'Oper', 'Address'])) 723 | 724 | def testSortTable(self): 725 | # pylint: disable=invalid-name 726 | def MakeTable(): 727 | t = texttable.TextTable() 728 | t.header = ('Col1', 'Col2', 'Col3') 729 | t.Append(('lorem', 'ipsum', 'dolor')) 730 | t.Append(('ut', 'enim', 'ad')) 731 | t.Append(('duis', 'aute', 'irure')) 732 | return t 733 | # Test basic sort 734 | table = MakeTable() 735 | table.sort() 736 | self.assertEqual(['duis', 'aute', 'irure'], table[1].values) 737 | self.assertEqual(['lorem', 'ipsum', 'dolor'], table[2].values) 738 | self.assertEqual(['ut', 'enim', 'ad'], table[3].values) 739 | 740 | # Test with different key 741 | table = MakeTable() 742 | table.sort(key=lambda x: x['Col2']) 743 | self.assertEqual(['duis', 'aute', 'irure'], table[1].values) 744 | self.assertEqual(['ut', 'enim', 'ad'], table[2].values) 745 | self.assertEqual(['lorem', 'ipsum', 'dolor'], table[3].values) 746 | 747 | # Multiple keys. 748 | table = MakeTable() 749 | table.Append(('duis', 'aute', 'aute')) 750 | table.sort(key=lambda x: x['Col2', 'Col3']) 751 | self.assertEqual(['duis', 'aute', 'aute'], table[1].values) 752 | self.assertEqual(['duis', 'aute', 'irure'], table[2].values) 753 | 754 | # Test with custom compare 755 | # pylint: disable=C6409 756 | def compare(a, b): 757 | # Compare from 2nd char of 1st col 758 | return cmp(a[0][1:], b[0][1:]) 759 | table = MakeTable() 760 | table.sort(cmp=compare) 761 | self.assertEqual(['lorem', 'ipsum', 'dolor'], table[1].values) 762 | self.assertEqual(['ut', 'enim', 'ad'], table[2].values) 763 | self.assertEqual(['duis', 'aute', 'irure'], table[3].values) 764 | # Set the key, so the 1st col compared is 'Col2'. 765 | table.sort(key=lambda x: x['Col2'], cmp=compare) 766 | self.assertEqual(['ut', 'enim', 'ad'], table[2].values) 767 | self.assertEqual(['lorem', 'ipsum', 'dolor'], table[1].values) 768 | self.assertEqual(['duis', 'aute', 'irure'], table[3].values) 769 | 770 | # Sort in reverse order. 771 | table.sort(key=lambda x: x['Col2'], reverse=True) 772 | self.assertEqual(['lorem', 'ipsum', 'dolor'], table[1].values) 773 | self.assertEqual(['ut', 'enim', 'ad'], table[2].values) 774 | self.assertEqual(['duis', 'aute', 'irure'], table[3].values) 775 | 776 | 777 | if __name__ == '__main__': 778 | unittest.main() 779 | -------------------------------------------------------------------------------- /textfsm/__init__.py: -------------------------------------------------------------------------------- 1 | """Template based text parser. 2 | 3 | This module implements a parser, intended to be used for converting 4 | human readable text, such as command output from a router CLI, into 5 | a list of records, containing values extracted from the input text. 6 | 7 | A simple template language is used to describe a state machine to 8 | parse a specific type of text input, returning a record of values 9 | for each input entity. 10 | """ 11 | from textfsm.parser import * 12 | 13 | __version__ = '2.1.0' 14 | -------------------------------------------------------------------------------- /textfsm/clitable.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | # Copyright 2022 Google Inc. All Rights Reserved. 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 14 | # implied. See the License for the specific language governing 15 | # permissions and limitations under the License. 16 | 17 | """TCLI Table - CLI data in TextTable format. 18 | 19 | Class that reads CLI output and parses into tabular format. 20 | 21 | Supports the use of index files to map TextFSM templates to device/command 22 | output combinations and store the data in a TextTable. 23 | 24 | Is the glue between an automated command scraping program (such as RANCID) and 25 | the TextFSM output parser. 26 | """ 27 | 28 | import copy 29 | import os 30 | import re 31 | import threading 32 | import textfsm 33 | from textfsm import texttable 34 | 35 | 36 | class Error(Exception): 37 | """Base class for errors.""" 38 | 39 | 40 | class IndexTableError(Error): 41 | """General IndexTable error.""" 42 | 43 | 44 | class CliTableError(Error): # pylint: disable=g-bad-exception-name 45 | """General CliTable error.""" 46 | 47 | 48 | class IndexTable(object): 49 | """Class that reads and stores comma-separated values as a TextTable. 50 | 51 | Stores a compiled regexp of the value for efficient matching. 52 | 53 | Includes functions to preprocess Columns (both compiled and uncompiled). 54 | 55 | Attributes: 56 | index: TextTable, the index file parsed into a texttable. 57 | compiled: TextTable, the table but with compiled regexp for each field. 58 | """ 59 | 60 | def __init__(self, preread=None, precompile=None, file_path=None): 61 | """Create new IndexTable object. 62 | 63 | Args: 64 | preread: func, Pre-processing, applied to each field as it is read. 65 | precompile: func, Pre-compilation, applied to each field before compiling. 66 | file_path: String, Location of file to use as input. 67 | """ 68 | self.index = None 69 | self.compiled = None 70 | if file_path: 71 | self._index_file = file_path 72 | self._index_handle = open(self._index_file, 'r') 73 | self._ParseIndex(preread, precompile) 74 | 75 | def __del__(self): 76 | """Close index handle.""" 77 | if hasattr(self, '_index_handle'): 78 | self._index_handle.close() 79 | 80 | def __len__(self): 81 | """Returns number of rows in table.""" 82 | return self.index.size 83 | 84 | def __copy__(self): 85 | """Returns a copy of an IndexTable object.""" 86 | clone = IndexTable() 87 | if hasattr(self, '_index_file'): 88 | # pylint: disable=protected-access 89 | clone._index_file = self._index_file 90 | clone._index_handle = self._index_handle 91 | 92 | clone.index = self.index 93 | clone.compiled = self.compiled 94 | return clone 95 | 96 | def __deepcopy__(self, memodict=None): 97 | """Returns a deepcopy of an IndexTable object.""" 98 | clone = IndexTable() 99 | if hasattr(self, '_index_file'): 100 | # pylint: disable=protected-access 101 | clone._index_file = copy.deepcopy(self._index_file) 102 | clone._index_handle = open(clone._index_file, 'r') 103 | 104 | clone.index = copy.deepcopy(self.index) 105 | clone.compiled = copy.deepcopy(self.compiled) 106 | return clone 107 | 108 | def _ParseIndex(self, preread, precompile): 109 | """Reads index file and stores entries in TextTable. 110 | 111 | For optimisation reasons, a second table is created with compiled entries. 112 | 113 | Args: 114 | preread: func, Pre-processing, applied to each field as it is read. 115 | precompile: func, Pre-compilation, applied to each field before compiling. 116 | 117 | Raises: 118 | IndexTableError: If the column headers has illegal column labels. 119 | """ 120 | self.index = texttable.TextTable() 121 | self.index.CsvToTable(self._index_handle) 122 | 123 | if preread: 124 | for row in self.index: 125 | for col in row.header: 126 | row[col] = preread(col, row[col]) 127 | 128 | self.compiled = copy.deepcopy(self.index) 129 | 130 | for row in self.compiled: 131 | for col in row.header: 132 | if precompile: 133 | row[col] = precompile(col, row[col]) 134 | if row[col]: 135 | row[col] = re.compile(row[col]) 136 | 137 | def GetRowMatch(self, attributes): 138 | """Returns the row number that matches the supplied attributes.""" 139 | for row in self.compiled: 140 | try: 141 | for key in attributes: 142 | # Silently skip attributes not present in the index file. 143 | # pylint: disable=E1103 144 | if ( 145 | key in row.header 146 | and row[key] 147 | and not row[key].match(attributes[key]) 148 | ): 149 | # This line does not match, so break and try next row. 150 | raise StopIteration() 151 | return row.row 152 | except StopIteration: 153 | pass 154 | return 0 155 | 156 | 157 | class CliTable(texttable.TextTable): 158 | """Class that reads CLI output and parses into tabular format. 159 | 160 | Reads an index file and uses it to map command strings to templates. It then 161 | uses TextFSM to parse the command output (raw) into a tabular format. 162 | 163 | The superkey is the set of columns that contain data that uniquely defines the 164 | row, the key is the row number otherwise. This is typically gathered from the 165 | templates 'Key' value but is extensible. 166 | 167 | Attributes: 168 | raw: String, Unparsed command string from device/command. 169 | index_file: String, file where template/command mappings reside. 170 | template_dir: String, directory where index file and templates reside. 171 | """ 172 | 173 | # Parse each template index only once across all instances. 174 | # Without this, the regexes are parsed at every call to CliTable(). 175 | _lock = threading.Lock() 176 | INDEX = {} 177 | 178 | def synchronised(func): 179 | """Synchronisation decorator.""" 180 | 181 | # pylint: disable=E0213 182 | def Wrapper(main_obj, *args, **kwargs): 183 | main_obj._lock.acquire() # pylint: disable=W0212 184 | try: 185 | return func(main_obj, *args, **kwargs) # pylint: disable=E1102 186 | finally: 187 | main_obj._lock.release() # pylint: disable=W0212 188 | 189 | return Wrapper 190 | 191 | @synchronised 192 | def __init__(self, index_file=None, template_dir=None): 193 | """Create new CLiTable object. 194 | 195 | Args: 196 | index_file: String, file where template/command mappings reside. 197 | template_dir: String, directory where index file and templates reside. 198 | """ 199 | # pylint: disable=E1002 200 | super(CliTable, self).__init__() 201 | self._keys = set() 202 | self.raw = None 203 | self.index_file = index_file 204 | self.template_dir = template_dir 205 | if index_file: 206 | self.ReadIndex(index_file) 207 | 208 | def ReadIndex(self, index_file=None): 209 | """Reads the IndexTable index file of commands and templates. 210 | 211 | Args: 212 | index_file: String, file where template/command mappings reside. 213 | 214 | Raises: 215 | CliTableError: A template column was not found in the table. 216 | """ 217 | 218 | self.index_file = index_file or self.index_file 219 | fullpath = os.path.join(self.template_dir, self.index_file) 220 | if self.index_file and fullpath not in self.INDEX: 221 | self.index = IndexTable(self._PreParse, self._PreCompile, fullpath) 222 | self.INDEX[fullpath] = self.index 223 | else: 224 | self.index = self.INDEX[fullpath] 225 | 226 | # Does the IndexTable have the right columns. 227 | if 'Template' not in self.index.index.header: # pylint: disable=E1103 228 | raise CliTableError("Index file does not have 'Template' column.") 229 | 230 | def _TemplateNamesToFiles(self, template_str): 231 | """Parses a string of templates into a list of file handles.""" 232 | 233 | template_list = template_str.split(':') 234 | template_files = [] 235 | try: 236 | for tmplt in template_list: 237 | template_files.append(open(os.path.join(self.template_dir, tmplt), 'r')) 238 | except: 239 | for tmplt in template_files: 240 | tmplt.close() 241 | raise 242 | 243 | return template_files 244 | 245 | def ParseCmd(self, cmd_input, attributes=None, templates=None): 246 | """Creates a TextTable table of values from cmd_input string. 247 | 248 | Parses command output with template/s. If more than one template is found 249 | subsequent tables are merged if keys match (dropped otherwise). 250 | 251 | Args: 252 | cmd_input: String, Device/command response. 253 | attributes: Dict, attribute that further refine matching template. 254 | templates: String list of templates to parse with. If None, uses index 255 | 256 | Raises: 257 | CliTableError: A template was not found for the given command. 258 | """ 259 | # Store raw command data within the object. 260 | self.raw = cmd_input 261 | 262 | if not templates: 263 | # Find template in template index. 264 | row_idx = self.index.GetRowMatch(attributes) 265 | if row_idx: 266 | templates = self.index.index[row_idx]['Template'] 267 | else: 268 | raise CliTableError( 269 | 'No template found for attributes: "%s"' % attributes 270 | ) 271 | 272 | template_files = self._TemplateNamesToFiles(templates) 273 | 274 | try: 275 | # Re-initialise the table. 276 | self.Reset() 277 | self._keys = set() 278 | self.table = self._ParseCmdItem(self.raw, template_file=template_files[0]) 279 | 280 | # Add additional columns from any additional tables. 281 | for tmplt in template_files[1:]: 282 | self.extend( 283 | self._ParseCmdItem(self.raw, template_file=tmplt), set(self._keys) 284 | ) 285 | finally: 286 | for f in template_files: 287 | f.close() 288 | 289 | def _ParseCmdItem(self, cmd_input, template_file=None): 290 | """Creates Texttable with output of command. 291 | 292 | Args: 293 | cmd_input: String, Device response. 294 | template_file: File object, template to parse with. 295 | 296 | Returns: 297 | TextTable containing command output. 298 | 299 | Raises: 300 | CliTableError: A template was not found for the given command. 301 | """ 302 | # Build FSM machine from the template. 303 | fsm = textfsm.TextFSM(template_file) 304 | if not self._keys: 305 | self._keys = set(fsm.GetValuesByAttrib('Key')) 306 | 307 | # Pass raw data through FSM. 308 | table = texttable.TextTable() 309 | table.header = fsm.header 310 | 311 | # Fill TextTable from record entries. 312 | for record in fsm.ParseText(cmd_input): 313 | table.Append(record) 314 | return table 315 | 316 | def _PreParse(self, key, value): 317 | """Executed against each field of each row read from index table.""" 318 | if key == 'Command': 319 | return re.sub(r'(\[\[.+?\]\])', self._Completion, value) 320 | else: 321 | return value 322 | 323 | def _PreCompile(self, key, value): 324 | """Executed against each field of each row before compiling as regexp.""" 325 | if key == 'Template': 326 | return 327 | else: 328 | return value 329 | 330 | def _Completion(self, match): 331 | r"""Replaces double square brackets with variable length completion. 332 | 333 | Completion cannot be mixed with regexp matching or '\' characters 334 | i.e. '[[(\n)]] would become (\(n)?)?.' 335 | 336 | Args: 337 | match: A regex Match() object. 338 | 339 | Returns: 340 | String of the format '(a(b(c(d)?)?)?)?'. 341 | """ 342 | # Strip the outer '[[' & ']]' and replace with ()? regexp pattern. 343 | word = str(match.group())[2:-2] 344 | return '(' + ('(').join(word) + ')?' * len(word) 345 | 346 | def LabelValueTable(self, keys=None): 347 | """Return LabelValue with FSM derived keys.""" 348 | keys = keys or self.superkey 349 | # pylint: disable=E1002 350 | return super(CliTable, self).LabelValueTable(keys) 351 | 352 | # pylint: disable=W0622 353 | def sort(self, cmp=None, key=None, reverse=False): 354 | """Overrides sort func to use the KeyValue for the key.""" 355 | if not key and self._keys: 356 | key = self.KeyValue 357 | super(CliTable, self).sort(cmp=cmp, key=key, reverse=reverse) 358 | 359 | # pylint: enable=W0622 360 | 361 | def AddKeys(self, key_list): 362 | """Mark additional columns as being part of the superkey. 363 | 364 | Supplements the Keys already extracted from the FSM template. 365 | Useful when adding new columns to existing tables. 366 | Note: This will impact attempts to further 'extend' the table as the 367 | superkey must be common between tables for successful extension. 368 | 369 | Args: 370 | key_list: list of header entries to be included in the superkey. 371 | 372 | Raises: 373 | KeyError: If any entry in list is not a valid header entry. 374 | """ 375 | 376 | for keyname in key_list: 377 | if keyname not in self.header: 378 | raise KeyError("'%s'" % keyname) 379 | 380 | self._keys = self._keys.union(set(key_list)) 381 | 382 | @property 383 | def superkey(self): 384 | """Returns a set of column names that together constitute the superkey.""" 385 | sorted_list = [] 386 | for header in self.header: 387 | if header in self._keys: 388 | sorted_list.append(header) 389 | return sorted_list 390 | 391 | def KeyValue(self, row=None): 392 | """Returns the super key value for the row.""" 393 | if not row: 394 | if self._iterator: 395 | # If we are inside an iterator use current row iteration. 396 | row = self[self._iterator] 397 | else: 398 | row = self.row 399 | # If no superkey then use row number. 400 | if not self.superkey: 401 | return ['%s' % row.row] 402 | 403 | sorted_list = [] 404 | for header in self.header: 405 | if header in self.superkey: 406 | sorted_list.append(row[header]) 407 | return sorted_list 408 | -------------------------------------------------------------------------------- /textfsm/terminal.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | # Copyright 2011 Google Inc. All Rights Reserved. 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 | """Simple terminal related routines.""" 19 | 20 | import getopt 21 | import re 22 | import shutil 23 | import sys 24 | import time 25 | import typing 26 | 27 | # ANSI, ISO/IEC 6429 escape sequences, SGR (Select Graphic Rendition) subset. 28 | SGR = { 29 | 'reset': 0, 30 | 'bold': 1, 31 | 'underline': 4, 32 | 'blink': 5, 33 | 'negative': 7, 34 | 'underline_off': 24, 35 | 'blink_off': 25, 36 | 'positive': 27, 37 | 'black': 30, 38 | 'red': 31, 39 | 'green': 32, 40 | 'yellow': 33, 41 | 'blue': 34, 42 | 'magenta': 35, 43 | 'cyan': 36, 44 | 'white': 37, 45 | 'fg_reset': 39, 46 | 'bg_black': 40, 47 | 'bg_red': 41, 48 | 'bg_green': 42, 49 | 'bg_yellow': 43, 50 | 'bg_blue': 44, 51 | 'bg_magenta': 45, 52 | 'bg_cyan': 46, 53 | 'bg_white': 47, 54 | 'bg_reset': 49, 55 | } 56 | 57 | # Provide a familar descriptive word for some ansi sequences. 58 | FG_COLOR_WORDS = { 59 | 'black': ['black'], 60 | 'dark_gray': ['bold', 'black'], 61 | 'blue': ['blue'], 62 | 'light_blue': ['bold', 'blue'], 63 | 'green': ['green'], 64 | 'light_green': ['bold', 'green'], 65 | 'cyan': ['cyan'], 66 | 'light_cyan': ['bold', 'cyan'], 67 | 'red': ['red'], 68 | 'light_red': ['bold', 'red'], 69 | 'purple': ['magenta'], 70 | 'light_purple': ['bold', 'magenta'], 71 | 'brown': ['yellow'], 72 | 'yellow': ['bold', 'yellow'], 73 | 'light_gray': ['white'], 74 | 'white': ['bold', 'white'], 75 | } 76 | 77 | BG_COLOR_WORDS = { 78 | 'black': ['bg_black'], 79 | 'red': ['bg_red'], 80 | 'green': ['bg_green'], 81 | 'yellow': ['bg_yellow'], 82 | 'dark_blue': ['bg_blue'], 83 | 'purple': ['bg_magenta'], 84 | 'light_blue': ['bg_cyan'], 85 | 'grey': ['bg_white'], 86 | } 87 | 88 | # Characters inserted at the start and end of ANSI strings 89 | # to provide hinting for readline and other clients. 90 | ANSI_START = '\001' 91 | ANSI_END = '\002' 92 | 93 | # Arrow key sequences. 94 | UP_ARROW = '\033[A' 95 | DOWN_ARROW = '\033[B' 96 | 97 | # Clear the screen and move the cursor to the top left. 98 | CLEAR_SCREEN = '\033[2J\033[H' 99 | 100 | # Navigational instructions for the user of the pager. 101 | PROMPT_QUESTION = 'n: next line, Space: next page, b: prev page, q: quit.' 102 | 103 | 104 | def _GetChar() -> str: 105 | """Read a single character from the tty. 106 | 107 | Returns: 108 | A string, the character read. 109 | """ 110 | # Default to 'q' to quit out of paging content. 111 | return 'q' 112 | 113 | try: 114 | # Import fails on Windows machines. 115 | # pylint: disable=g-import-not-at-top 116 | import termios 117 | import tty 118 | 119 | def _PosixGetChar() -> str: 120 | """Read a single character from the tty.""" 121 | try: 122 | read_tty = open('/dev/tty') 123 | except IOError: 124 | # No TTY, revert to stdin 125 | read_tty = sys.stdin 126 | fd = read_tty.fileno() 127 | old = termios.tcgetattr(fd) 128 | try: 129 | tty.setraw(fd) 130 | ch = read_tty.read(1) 131 | # Also support arrow key shortcuts (escape + 2 chars) 132 | if ord(ch) == 27: 133 | ch += read_tty.read(2) 134 | finally: 135 | termios.tcsetattr(fd, termios.TCSADRAIN, old) 136 | if '_tty' != sys.stdin: 137 | read_tty.close() 138 | return ch 139 | _GetChar = _PosixGetChar 140 | except (ImportError, ModuleNotFoundError): 141 | # If we are on MS Windows then try using msvcrt library instead. 142 | import msvcrt 143 | def _MSGetChar() -> str: 144 | ch = msvcrt.getch() # type: ignore 145 | # Also support arrow key shortcuts (escape + 2 chars) 146 | if ord(ch) == 27: 147 | ch += msvcrt.getch() # type: ignore 148 | return ch 149 | _GetChar = _MSGetChar 150 | 151 | # Regular expression to match ANSI/SGR escape sequences. 152 | sgr_re = re.compile(r'(%s?\033\[\d+(?:;\d+)*m%s?)' % (ANSI_START, ANSI_END)) 153 | 154 | 155 | class Error(Exception): 156 | """The base error class.""" 157 | 158 | 159 | class UsageError(Error): 160 | """Command line format error.""" 161 | 162 | 163 | def _AnsiCmd(command_list): 164 | """Takes a list of SGR values and formats them as an ANSI escape sequence. 165 | 166 | Args: 167 | command_list: List of strings, each string represents an SGR value. e.g. 168 | 'fg_blue', 'bg_yellow' 169 | 170 | Returns: 171 | The ANSI escape sequence. 172 | 173 | Raises: 174 | ValueError: if a member of command_list does not map to a valid SGR value. 175 | """ 176 | if not isinstance(command_list, list): 177 | raise ValueError('Invalid list: %s' % command_list) 178 | # Checks that entries are valid SGR names. 179 | # No checking is done for sequences that are correct but 'nonsensical'. 180 | for sgr in command_list: 181 | if sgr.lower() not in SGR: 182 | raise ValueError('Invalid or unsupported SGR name: %s' % sgr) 183 | # Convert to numerical strings. 184 | command_str = [str(SGR[x.lower()]) for x in command_list] 185 | # Wrap values in Ansi escape sequence (CSI prefix & SGR suffix). 186 | return '\033[%sm' % ';'.join(command_str) 187 | 188 | 189 | def AnsiText(text, command_list=None, reset=True): 190 | """Wrap text in ANSI/SGR escape codes. 191 | 192 | Args: 193 | text: String to encase in sgr escape sequence. 194 | command_list: List of strings, each string represents an sgr value. e.g. 195 | 'fg_blue', 'bg_yellow' 196 | reset: Boolean, if to add a reset sequence to the suffix of the text. 197 | 198 | Returns: 199 | String with sgr characters added. 200 | """ 201 | command_list = command_list or ['reset'] 202 | if reset: 203 | return '%s%s%s' % (_AnsiCmd(command_list), text, _AnsiCmd(['reset'])) 204 | else: 205 | return '%s%s' % (_AnsiCmd(command_list), text) 206 | 207 | 208 | def StripAnsiText(text): 209 | """Strip ANSI/SGR escape sequences from text.""" 210 | return sgr_re.sub('', text) 211 | 212 | 213 | def EncloseAnsiText(text): 214 | """Enclose ANSI/SGR escape sequences with ANSI_START and ANSI_END.""" 215 | return sgr_re.sub(lambda x: ANSI_START + x.group(1) + ANSI_END, text) 216 | 217 | 218 | def LineWrap(text, omit_sgr=False): 219 | """Break line to fit screen width, factoring in ANSI/SGR escape sequences. 220 | 221 | Args: 222 | text: String to line wrap. 223 | omit_sgr: Bool, to omit counting ANSI/SGR sequences in the length. 224 | 225 | Returns: 226 | Text with additional line wraps inserted for lines grater than the width. 227 | """ 228 | 229 | def _SplitWithSgr(text_line, width): 230 | """Tokenise the line so that the sgr sequences can be omitted.""" 231 | token_list = sgr_re.split(text_line) 232 | text_line_list = [] 233 | line_length = 0 234 | for index, token in enumerate(token_list): 235 | # Skip null tokens. 236 | if not token: 237 | continue 238 | 239 | if sgr_re.match(token): 240 | # Add sgr escape sequences without splitting or counting length. 241 | text_line_list.append(token) 242 | text_line = ''.join(token_list[index + 1 :]) 243 | else: 244 | if line_length + len(token) <= width: 245 | # Token fits in line and we count it towards overall length. 246 | text_line_list.append(token) 247 | line_length += len(token) 248 | text_line = ''.join(token_list[index + 1 :]) 249 | else: 250 | # Line splits part way through this token. 251 | # So split the token, form a new line and carry the remainder. 252 | text_line_list.append(token[: width - line_length]) 253 | text_line = token[width - line_length :] 254 | text_line += ''.join(token_list[index + 1 :]) 255 | break 256 | 257 | return (''.join(text_line_list), text_line) 258 | 259 | # We don't use textwrap library here as it insists on removing 260 | # trailing/leading whitespace (pre 2.6). 261 | (term_width, _) = shutil.get_terminal_size() 262 | text = str(text) 263 | text_multiline = [] 264 | for text_line in text.splitlines(): 265 | if not text_line: 266 | # Empty line, just add it. 267 | text_multiline.append(text_line) 268 | continue 269 | 270 | # Is this a line that needs splitting? 271 | while (omit_sgr and (len(StripAnsiText(text_line)) > term_width)) or ( 272 | len(text_line) > term_width 273 | ): 274 | # If there are no sgr escape characters then do a straight split. 275 | if not omit_sgr: 276 | text_multiline.append(text_line[:term_width]) 277 | text_line = text_line[term_width:] 278 | else: 279 | (multiline_line, text_line) = _SplitWithSgr(text_line, term_width) 280 | text_multiline.append(multiline_line) 281 | # If we have any text left over then add it. 282 | if text_line: 283 | text_multiline.append(text_line) 284 | return '\n'.join(text_multiline) 285 | 286 | 287 | class Pager(object): 288 | """A simple text pager module. 289 | 290 | Supports paging of text on a terminal, somewhat like a simple 'more' or 291 | 'less', but in pure Python. 292 | 293 | The simplest usage: 294 | 295 | with open('file.txt') as f: 296 | s = f.read() 297 | Pager(s).Page() 298 | 299 | Particularly unique is the ability to sequentially feed new text into the 300 | pager: 301 | 302 | p = Pager() 303 | for line in socket.read(): 304 | p.Page(line) 305 | 306 | If done this way, the Page() method will block until either the line has been 307 | displayed, or the user has quit the pager. 308 | 309 | Currently supported keybindings are: 310 | n - one line down 311 | - one line down 312 | b - one page up 313 | - one line up 314 | q - Quit the pager 315 | g - scroll to the end 316 | - one page down 317 | """ 318 | 319 | def __init__(self, text: str = '', delay: bool = False) -> None: 320 | """Constructor. 321 | 322 | Args: 323 | text: A string, the text that will be paged through. 324 | delay: A boolean, if True will cause a slight delay between line printing 325 | for more obvious scrolling. 326 | """ 327 | self._text = text 328 | self.Reset() 329 | self.SetLines() 330 | # Add 0.005 sec delay between lines. 331 | if delay: 332 | self._delay = 0.005 333 | else: 334 | self._delay = 0 335 | 336 | def Reset(self) -> None: 337 | """Reset the pager to the top of the text.""" 338 | self.first_line = 0 339 | 340 | def SetLines(self, num_lines: int = 0) -> typing.Tuple[int, int]: 341 | """Set number of lines to display at a time. 342 | 343 | Args: 344 | num_lines: An int, number of lines. If 0 use terminal dimensions. 345 | Maximum number should be one less than full terminal height, 346 | to allow for a user prompt. 347 | 348 | Raises: 349 | ValueError, TypeError: Not a valid integer representation. 350 | 351 | Returns: 352 | Tuple, the width and lines of the terminal. 353 | """ 354 | 355 | # Get the terminal size. 356 | (cols, lines) = shutil.get_terminal_size() 357 | # If we want paging by other than a whole window height. 358 | # For a whole window height, we drop one line to leave room for prompting. 359 | self._lines = int(num_lines) or lines - 1 360 | # Must be at least two rows, one row of output and one for the prompt. 361 | self._lines = max(2, self._lines) 362 | # Only number of rows is user configurable, we keep the terminal width. 363 | self._cols = cols 364 | return (self._cols, self._lines) 365 | 366 | def Clear(self) -> None: 367 | """Clear the text and reset the pager.""" 368 | self._text = '' 369 | self.Reset() 370 | 371 | def _Display(self, start: int, length: int = 0 372 | ) -> typing.Tuple[int, float, int]: 373 | """Display a range of lines from the text. 374 | 375 | Args: 376 | start: An int, the first line to display. 377 | length: An int, the number of lines to display. 378 | Returns: 379 | Tuple, the next line after, and a percentage for where that line is. 380 | """ 381 | 382 | # Break text on newlines. But also break on line wrap. 383 | text_list = LineWrap(self._text).splitlines() 384 | total_length = len(text_list) 385 | 386 | # Bound start and end to be within the text. 387 | start = max(0, start) 388 | # If open-ended, trim to be whole of text. 389 | if not length: 390 | end = total_length 391 | else: 392 | end = min(start + length, total_length) 393 | 394 | self._WriteOut(CLEAR_SCREEN) 395 | for i in range(start, end): 396 | print(text_list[i]) 397 | if self._delay: 398 | time.sleep(self._delay) 399 | 400 | return (end, end / len(text_list) * 100, total_length) 401 | 402 | def _WriteOut(self, text: str) -> None: 403 | """Write text to stdout.""" 404 | sys.stdout.write(text) 405 | sys.stdout.flush() 406 | 407 | def Page(self, more_text: str = '') -> None: 408 | """Page text. 409 | 410 | Continues to page through any text supplied in the constructor. Also, any 411 | text supplied to this method will be appended to the total text to be 412 | displayed. The method returns when all available text has been displayed to 413 | the user, or the user quits the pager. 414 | 415 | Args: 416 | more_text: A string, extra text to be appended. 417 | 418 | Returns: 419 | A boolean: True: we have reached the end. False: the user has quit early. 420 | """ 421 | 422 | # With each page, more text can be added. 423 | if more_text: 424 | self._text += more_text 425 | 426 | only_quit = False 427 | # Display a page of output. 428 | (end, percent, total_length) = self._Display(self.first_line, self._lines) 429 | # If less than a page to display, then 'quit' is only navigation option. 430 | if total_length < self._lines: 431 | only_quit = True 432 | 433 | # While there is more text to be displayed. 434 | while True: 435 | # If we are not reading streamed data then show % completion. 436 | if not more_text: 437 | wish = self._PromptUser(' (%d%%)' % percent) 438 | else: 439 | # If we are reading streamed data then show the prompt only. 440 | wish = self._PromptUser() 441 | 442 | if wish == 'q': # Quit. 443 | break 444 | 445 | if only_quit: 446 | # If we have less than a page of text, ignore navigational keys. 447 | continue 448 | 449 | if wish == 'g': # Display the remaining content. 450 | (end, _, total_length) = self._Display(end) 451 | self.first_line = end - self._lines 452 | elif wish == 'n': 453 | # Enter, down a line. 454 | self.first_line += 1 455 | elif wish == DOWN_ARROW: 456 | # Down a line. 457 | self.first_line += 1 458 | elif wish == UP_ARROW: 459 | # Up a line. 460 | self.first_line -= 1 461 | elif wish == 'b': 462 | # Up a page. 463 | self.first_line -= self._lines 464 | else: 465 | # Down a page. 466 | self.first_line += self._lines 467 | 468 | # Bound the first line to be within the text. 469 | self.first_line = max(0, self.first_line) 470 | self.first_line = min(total_length-self._lines, self.first_line) 471 | # Display a page of output. 472 | (end, percent, total_length) = self._Display( 473 | self.first_line, self._lines) 474 | 475 | # Set first_line to the end, so when we next page we start from there. 476 | self.first_line = end 477 | 478 | def _Prompt(self, suffix='') -> str: 479 | question = PROMPT_QUESTION + suffix 480 | # Truncate prompt to width of display. 481 | question = question[:self._cols] 482 | # Colorize the prompt. 483 | return AnsiText(question, ['green']) 484 | 485 | def _ClearPrompt(self) -> str: 486 | """Clear the prompt by over printing blank characters.""" 487 | return '\r%s\r' % (' ' * self._cols) 488 | 489 | def _PromptUser(self, suffix='') -> str: 490 | """Prompt the user for the next action. 491 | 492 | Args: 493 | suffix: A string, to be appended to the prompt. 494 | 495 | Returns: 496 | A string, the character entered by the user. 497 | """ 498 | 499 | self._WriteOut(self._Prompt(suffix)) 500 | ch = _GetChar() 501 | self._WriteOut(self._ClearPrompt()) 502 | return ch 503 | 504 | 505 | def main(argv=None): 506 | """Routine to page text or determine window size via command line.""" 507 | 508 | if argv is None: 509 | argv = sys.argv 510 | 511 | try: 512 | opts, args = getopt.getopt(argv[1:], 'dhs', ['nodelay', 'help', 'size']) 513 | except getopt.error as exc: 514 | raise UsageError(exc) from exc 515 | 516 | # Print usage and return, regardless of presence of other args. 517 | for opt, _ in opts: 518 | if opt in ('-h', '--help'): 519 | print(__doc__) 520 | print(help_msg) 521 | return 0 522 | 523 | is_delay = False 524 | for opt, _ in opts: 525 | # Prints the size of the terminal and returns. 526 | # Mutually exclusive to the paging of text and overrides that behavior. 527 | if opt in ('-s', '--size'): 528 | print('Width: %d, Length: %d' % shutil.get_terminal_size()) 529 | return 0 530 | elif opt in ('-d', '--delay'): 531 | is_delay = True 532 | else: 533 | raise UsageError('Invalid arguments.') 534 | 535 | # Page text supplied in either specified file or stdin. 536 | 537 | if len(args) == 1: 538 | with open(args[0], 'r') as f: 539 | fd = f.read() 540 | else: 541 | fd = sys.stdin.read() 542 | Pager(fd, delay=is_delay).Page() 543 | 544 | 545 | if __name__ == '__main__': 546 | help_msg = '%s [--help] [--size] [--nodelay] [input_file]\n' % sys.argv[0] 547 | try: 548 | sys.exit(main()) 549 | except UsageError as err: 550 | print(err, file=sys.stderr) 551 | print('For help use --help', file=sys.stderr) 552 | sys.exit(2) 553 | -------------------------------------------------------------------------------- /textfsm/texttable.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | # Copyright 2012 Google Inc. All Rights Reserved. 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 14 | # implied. See the License for the specific language governing 15 | # permissions and limitations under the License. 16 | 17 | """A module to represent and manipulate tabular text data. 18 | 19 | A table of rows, indexed on row number. Each row is a ordered dictionary of row 20 | elements that maintains knowledge of the parent table and column headings. 21 | 22 | Tables can be created from CSV input and in-turn supports a number of display 23 | formats such as CSV and variable sized and justified rows. 24 | """ 25 | 26 | import copy 27 | import functools 28 | import textwrap 29 | 30 | from textfsm import terminal 31 | 32 | 33 | class Error(Exception): 34 | """Base class for errors.""" 35 | 36 | 37 | class TableError(Error): 38 | """Error in TextTable.""" 39 | 40 | 41 | class Row(dict): 42 | """Represents a table row. We implement this as an ordered dictionary. 43 | 44 | The order is the chronological order of data insertion. Methods are supplied 45 | to make it behave like a regular dict() and list(). 46 | 47 | Attributes: 48 | color: Colour spec of this row. 49 | header: List of row's headers. 50 | row: int, the row number in the container table. 0 is the header row. 51 | table: A TextTable(), the associated container table. 52 | values: List of row's values. 53 | """ 54 | 55 | def __init__(self, *args, **kwargs): 56 | super(Row, self).__init__(*args, **kwargs) 57 | self._keys = list() 58 | self._values = list() 59 | self.row = None 60 | self.table = None 61 | self._color = None 62 | self._index = {} 63 | 64 | def _BuildIndex(self): 65 | """Recreate the key index.""" 66 | self._index = {} 67 | for i, k in enumerate(self._keys): 68 | self._index[k] = i 69 | 70 | def __getitem__(self, column): 71 | """Support for [] notation. 72 | 73 | Args: 74 | column: Tuple of column names, or a (str) column name, or positional 75 | column number, 0-indexed. 76 | 77 | Returns: 78 | A list or string with column value(s). 79 | 80 | Raises: 81 | IndexError: The given column(s) were not found. 82 | """ 83 | if isinstance(column, (list, tuple)): 84 | ret = [] 85 | for col in column: 86 | ret.append(self[col]) 87 | return ret 88 | 89 | try: 90 | return self._values[self._index[column]] 91 | except (KeyError, TypeError, ValueError): 92 | pass 93 | 94 | # Perhaps we have a range like '1', ':-1' or '1:'. 95 | try: 96 | return self._values[column] 97 | except (IndexError, TypeError): 98 | pass 99 | 100 | raise IndexError('No such column "%s" in row.' % column) 101 | 102 | def __contains__(self, value): 103 | return value in self._values 104 | 105 | def __setitem__(self, column, value): 106 | for i in range(len(self)): 107 | if self._keys[i] == column: 108 | self._values[i] = value 109 | return 110 | # No column found, add a new one. 111 | self._keys.append(column) 112 | self._values.append(value) 113 | self._BuildIndex() 114 | 115 | def __iter__(self): 116 | return iter(self._values) 117 | 118 | def __len__(self): 119 | return len(self._keys) 120 | 121 | def __str__(self): 122 | ret = '' 123 | for v in self._values: 124 | ret += '%12s ' % v 125 | ret += '\n' 126 | return ret 127 | 128 | def __repr__(self): 129 | return '%s(%r)' % (self.__class__.__name__, str(self)) 130 | 131 | def get(self, column, default_value=None): 132 | """Get an item from the Row by column name. 133 | 134 | Args: 135 | column: Tuple of column names, or a (str) column name, or positional 136 | column number, 0-indexed. 137 | default_value: The value to use if the key is not found. 138 | 139 | Returns: 140 | A list or string with column value(s) or default_value if not found. 141 | """ 142 | if isinstance(column, (list, tuple)): 143 | ret = [] 144 | for col in column: 145 | ret.append(self.get(col, default_value)) 146 | return ret 147 | # Perhaps we have a range like '1', ':-1' or '1:'. 148 | try: 149 | return self._values[column] 150 | except (IndexError, TypeError): 151 | pass 152 | try: 153 | return self[column] 154 | except IndexError: 155 | return default_value 156 | 157 | def index(self, column): # pylint: disable=invalid-name 158 | """Fetches the column number (0 indexed). 159 | 160 | Args: 161 | column: A string, column to fetch the index of. 162 | 163 | Returns: 164 | An int, the row index number. 165 | 166 | Raises: 167 | ValueError: The specified column was not found. 168 | """ 169 | try: 170 | return self._keys.index(column) 171 | except ValueError as exc: 172 | raise ValueError('Column "%s" not found.' % column) from exc 173 | 174 | def iterkeys(self): # pylint: disable=invalid-name 175 | return iter(self._keys) 176 | 177 | def items(self): 178 | # TODO(harro): self.get(k) should work here but didn't ? 179 | return [(k, self.__getitem__(k)) for k in self._keys] 180 | 181 | def _GetValues(self): 182 | """Return the row's values.""" 183 | return self._values 184 | 185 | def _GetHeader(self): 186 | """Return the row's header.""" 187 | return self._keys 188 | 189 | def _SetHeader(self, values): 190 | """Set the row's header from a list.""" 191 | if self._values and len(values) != len(self._values): 192 | raise ValueError('Header values not equal to existing data width.') 193 | if not self._values: 194 | for _ in range(len(values)): 195 | self._values.append(None) 196 | self._keys = list(values) 197 | self._BuildIndex() 198 | 199 | def _SetColour(self, value_list): 200 | """Sets row's colour attributes to a list of values in terminal.SGR.""" 201 | if value_list is None: 202 | self._color = None 203 | return 204 | colors = [] 205 | for color in value_list: 206 | if color in terminal.SGR: 207 | colors.append(color) 208 | elif color in terminal.FG_COLOR_WORDS: 209 | colors += terminal.FG_COLOR_WORDS[color] 210 | elif color in terminal.BG_COLOR_WORDS: 211 | colors += terminal.BG_COLOR_WORDS[color] 212 | else: 213 | raise ValueError('Invalid colour specification.') 214 | self._color = list(set(colors)) 215 | 216 | def _GetColour(self): 217 | if self._color is None: 218 | return None 219 | return list(self._color) 220 | 221 | def _SetValues(self, values): 222 | """Set values from supplied dictionary or list. 223 | 224 | Args: 225 | values: A Row, dict indexed by column name, or list. 226 | 227 | Raises: 228 | TypeError: Argument is not a list or dict, or list is not equal row 229 | length or dictionary keys don't match. 230 | """ 231 | 232 | def _ToStr(value): 233 | """Convert individul list entries to string.""" 234 | if isinstance(value, (list, tuple)): 235 | result = [] 236 | for val in value: 237 | result.append(str(val)) 238 | return result 239 | else: 240 | return str(value) 241 | 242 | # Row with identical header can be copied directly. 243 | if isinstance(values, Row): 244 | if self._keys != values.header: 245 | raise TypeError('Attempt to append row with mismatched header.') 246 | self._values = copy.deepcopy(values.values) 247 | 248 | elif isinstance(values, dict): 249 | for key in self._keys: 250 | if key not in values: 251 | raise TypeError('Dictionary key mismatch with row.') 252 | for key in self._keys: 253 | self[key] = _ToStr(values[key]) 254 | 255 | elif isinstance(values, list) or isinstance(values, tuple): 256 | if len(values) != len(self._values): 257 | raise TypeError('Supplied list length != row length') 258 | for index, value in enumerate(values): 259 | self._values[index] = _ToStr(value) 260 | 261 | else: 262 | raise TypeError( 263 | 'Supplied argument must be Row, dict or list, not %s' % type(values) 264 | ) 265 | 266 | def Insert(self, key, value, row_index): 267 | """Inserts new values at a specified offset. 268 | 269 | Args: 270 | key: string for header value. 271 | value: string for a data value. 272 | row_index: Offset into row for data. 273 | 274 | Raises: 275 | IndexError: If the offset is out of bands. 276 | """ 277 | if row_index < 0: 278 | row_index += len(self) 279 | 280 | if not 0 <= row_index < len(self): 281 | raise IndexError('Index "%s" is out of bounds.' % row_index) 282 | 283 | new_row = Row() 284 | for idx in self.header: 285 | if self.index(idx) == row_index: 286 | new_row[key] = value 287 | new_row[idx] = self[idx] 288 | self._keys = new_row.header 289 | self._values = new_row.values 290 | del new_row 291 | self._BuildIndex() 292 | 293 | color = property(_GetColour, _SetColour, doc='Colour spec of this row') 294 | header = property(_GetHeader, _SetHeader, doc="List of row's headers.") 295 | values = property(_GetValues, _SetValues, doc="List of row's values.") 296 | 297 | 298 | class TextTable(object): 299 | """Class that provides data methods on a tabular format. 300 | 301 | Data is stored as a list of Row() objects. The first row is always present as 302 | the header row. 303 | 304 | Attributes: 305 | row_class: class, A class to use for the Row object. 306 | separator: str, field separator when printing table. 307 | """ 308 | 309 | def __init__(self, row_class=Row): 310 | """Initialises a new table. 311 | 312 | Args: 313 | row_class: A class to use as the row object. This should be a subclass of 314 | this module's Row() class. 315 | """ 316 | self.row_class = row_class 317 | self.separator = ', ' 318 | self.Reset() 319 | 320 | def Reset(self): 321 | self._row_index = 1 322 | self._table = [[]] 323 | self._iterator = 0 # While loop row index 324 | 325 | def __repr__(self): 326 | return '%s(%r)' % (self.__class__.__name__, str(self)) 327 | 328 | def __str__(self): 329 | """Displays table with pretty formatting.""" 330 | return self.table 331 | 332 | def __incr__(self, incr=1): 333 | self._SetRowIndex(self._row_index + incr) 334 | 335 | def __contains__(self, name): 336 | """Whether the given column header name exists.""" 337 | return name in self.header 338 | 339 | def __getitem__(self, row): 340 | """Fetches the given row number.""" 341 | return self._table[row] 342 | 343 | def __iter__(self): 344 | """Iterator that excludes the header row.""" 345 | return next(self) 346 | 347 | def __next__(self): 348 | # Maintain a counter so a row can know what index it is. 349 | # Save the old value to support nested interations. 350 | old_iter = self._iterator 351 | try: 352 | for r in self._table[1:]: 353 | self._iterator = r.row 354 | yield r 355 | finally: 356 | # Recover the original index after loop termination or exit with break. 357 | self._iterator = old_iter 358 | 359 | def __add__(self, other): 360 | """Merges two with identical columns.""" 361 | 362 | new_table = copy.copy(self) 363 | for row in other: 364 | new_table.Append(row) 365 | 366 | return new_table 367 | 368 | def __copy__(self): 369 | """Copy table instance.""" 370 | 371 | new_table = self.__class__() 372 | # pylint: disable=protected-access 373 | new_table._table = [self.header] 374 | for row in self[1:]: 375 | new_table.Append(row) 376 | return new_table 377 | 378 | def Filter(self, function=None): 379 | """Construct Textable from the rows of which the function returns true. 380 | 381 | Args: 382 | function: A function applied to each row which returns a bool. If function 383 | is None, all rows with empty column values are removed. 384 | 385 | Returns: 386 | A new TextTable() 387 | 388 | Raises: 389 | TableError: When an invalid row entry is Append()'d 390 | """ 391 | flat = lambda x: x if isinstance(x, str) else ''.join([flat(y) for y in x]) 392 | if function is None: 393 | function = lambda row: bool(flat(row.values)) 394 | 395 | new_table = self.__class__() 396 | # pylint: disable=protected-access 397 | new_table._table = [self.header] 398 | for row in self: 399 | if function(row): 400 | new_table.Append(row) 401 | return new_table 402 | 403 | def Map(self, function): 404 | """Applies the function to every row in the table. 405 | 406 | Args: 407 | function: A function applied to each row. 408 | 409 | Returns: 410 | A new TextTable() 411 | 412 | Raises: 413 | TableError: When transform is not invalid row entry. The transform 414 | must be compatible with Append(). 415 | """ 416 | new_table = self.__class__() 417 | # pylint: disable=protected-access 418 | new_table._table = [self.header] 419 | for row in self: 420 | filtered_row = function(row) 421 | if filtered_row: 422 | new_table.Append(filtered_row) 423 | return new_table 424 | 425 | # pylint: disable=W0622 426 | # pylint: disable=invalid-name 427 | def sort(self, cmp=None, key=None, reverse=False): 428 | """Sorts rows in the texttable. 429 | 430 | Args: 431 | cmp: func, non default sort algorithm to use. 432 | key: func, applied to each element before sorting. 433 | reverse: bool, reverse order of sort. 434 | """ 435 | 436 | def _DefaultKey(value): 437 | """Default key func is to create a list of all fields.""" 438 | result = [] 439 | for key in self.header: 440 | # Try sorting as numerical value if possible. 441 | try: 442 | result.append(float(value[key])) 443 | except ValueError: 444 | result.append(value[key]) 445 | return result 446 | 447 | key = key or _DefaultKey 448 | # Exclude header by copying table. 449 | new_table = self._table[1:] 450 | 451 | if cmp is not None: 452 | key = functools.cmp_to_key(cmp) 453 | 454 | new_table.sort(key=key, reverse=reverse) 455 | 456 | # Regenerate the table with original header 457 | self._table = [self.header] 458 | self._table.extend(new_table) 459 | # Re-write the 'row' attribute of each row 460 | for index, row in enumerate(self._table): 461 | row.row = index 462 | 463 | # pylint: enable=W0622 464 | # pylint: enable=invalid-name 465 | 466 | def extend(self, table, keys=None): # pylint: disable=invalid-name 467 | """Extends all rows in the texttable. 468 | 469 | The rows are extended with the new columns from the table. 470 | 471 | Args: 472 | table: A texttable, the table to extend this table by. 473 | keys: A set, the set of columns to use as the key. If None, the row index 474 | is used. 475 | 476 | Raises: 477 | IndexError: If key is not a valid column name. 478 | """ 479 | if keys: 480 | for k in keys: 481 | if k not in self._Header(): 482 | raise IndexError("Unknown key: '%s'" % k) 483 | 484 | extend_with = [] 485 | for column in table.header: 486 | if column not in self.header: 487 | extend_with.append(column) 488 | 489 | if not extend_with: 490 | return 491 | 492 | for column in extend_with: 493 | self.AddColumn(column) 494 | 495 | if not keys: 496 | for row1, row2 in zip(self, table): 497 | for column in extend_with: 498 | row1[column] = row2[column] 499 | return 500 | 501 | for row1 in self: 502 | for row2 in table: 503 | for k in keys: 504 | if row1[k] != row2[k]: 505 | break 506 | else: 507 | for column in extend_with: 508 | row1[column] = row2[column] 509 | break 510 | 511 | def Remove(self, row): 512 | """Removes a row from the table. 513 | 514 | Args: 515 | row: int, the row number to delete. Must be >= 1, as the header cannot be 516 | removed. 517 | 518 | Raises: 519 | TableError: Attempt to remove nonexistent or header row. 520 | """ 521 | if row == 0 or row > self.size: 522 | raise TableError('Attempt to remove header row') 523 | new_table = [] 524 | # pylint: disable=E1103 525 | for t_row in self._table: 526 | if t_row.row != row: 527 | new_table.append(t_row) 528 | if t_row.row > row: 529 | t_row.row -= 1 530 | self._table = new_table 531 | 532 | def _Header(self): 533 | """Returns the header row.""" 534 | return self._table[0] 535 | 536 | def _GetRow(self, columns=None): 537 | """Returns the current row as a tuple.""" 538 | 539 | row = self._table[self._row_index] 540 | if columns: 541 | result = [] 542 | for col in columns: 543 | if col not in self.header: 544 | raise TableError('Column header %s not known in table.' % col) 545 | result.append(row[self.header.index(col)]) 546 | row = result 547 | return row 548 | 549 | def _SetRow(self, new_values, row=0): 550 | """Sets the current row to new list. 551 | 552 | Args: 553 | new_values: List|dict of new values to insert into row. 554 | row: int, Row to insert values into. 555 | 556 | Raises: 557 | TableError: If number of new values is not equal to row size. 558 | """ 559 | 560 | if not row: 561 | row = self._row_index 562 | 563 | if row > self.size: 564 | raise TableError('Entry %s beyond table size %s.' % (row, self.size)) 565 | 566 | self._table[row].values = new_values 567 | 568 | def _SetHeader(self, new_values): 569 | """Sets header of table to the given tuple. 570 | 571 | Args: 572 | new_values: Tuple of new header values. 573 | """ 574 | row = self.row_class() 575 | row.row = 0 576 | for v in new_values: 577 | row[v] = v 578 | self._table[0] = row 579 | 580 | def _SetRowIndex(self, row): 581 | if not row or row > self.size: 582 | raise TableError('Entry %s beyond table size %s.' % (row, self.size)) 583 | self._row_index = row 584 | 585 | def _GetRowIndex(self): 586 | return self._row_index 587 | 588 | def _GetSize(self): 589 | """Returns number of rows in table.""" 590 | 591 | if not self._table: 592 | return 0 593 | return len(self._table) - 1 594 | 595 | def _GetTable(self): 596 | """Returns table, with column headers and separators. 597 | 598 | Returns: 599 | The whole table including headers as a string. Each row is 600 | joined by a newline and each entry by self.separator. 601 | """ 602 | result = [] 603 | # Avoid the global lookup cost on each iteration. 604 | lstr = str 605 | for row in self._table: 606 | result.append('%s\n' % self.separator.join(lstr(v) for v in row)) 607 | 608 | return ''.join(result) 609 | 610 | def _SetTable(self, table): 611 | """Sets table, with column headers and separators.""" 612 | if not isinstance(table, TextTable): 613 | raise TypeError('Not an instance of TextTable.') 614 | self.Reset() 615 | self._table = copy.deepcopy(table._table) # pylint: disable=W0212 616 | # Point parent table of each row back ourselves. 617 | for row in self: 618 | row.table = self 619 | 620 | def _SmallestColSize(self, text): 621 | """Finds the largest indivisible word of a string. 622 | 623 | ...and thus the smallest possible column width that can contain that 624 | word unsplit over rows. 625 | 626 | Args: 627 | text: A string of text potentially consisting of words. 628 | 629 | Returns: 630 | Integer size of the largest single word in the text. 631 | """ 632 | if not text: 633 | return 0 634 | stripped = terminal.StripAnsiText(text) 635 | return max(len(word) for word in stripped.split()) 636 | 637 | def _TextJustify(self, text, col_size): 638 | """Formats text within column with white space padding. 639 | 640 | A single space is prefixed, and a number of spaces are added as a 641 | suffix such that the length of the resultant string equals the col_size. 642 | 643 | If the length of the text exceeds the column width available then it 644 | is split into words and returned as a list of string, each string 645 | contains one or more words padded to the column size. 646 | 647 | Args: 648 | text: String of text to format. 649 | col_size: integer size of column to pad out the text to. 650 | 651 | Returns: 652 | List of strings col_size in length. 653 | 654 | Raises: 655 | TableError: If col_size is too small to fit the words in the text. 656 | """ 657 | result = [] 658 | if '\n' in text: 659 | for paragraph in text.split('\n'): 660 | result.extend(self._TextJustify(paragraph, col_size)) 661 | return result 662 | 663 | wrapper = textwrap.TextWrapper( 664 | width=col_size - 2, break_long_words=False, expand_tabs=False 665 | ) 666 | try: 667 | text_list = wrapper.wrap(text) 668 | except ValueError as exc: 669 | raise TableError('Field too small (minimum width: 3)') from exc 670 | 671 | if not text_list: 672 | return [' ' * col_size] 673 | 674 | for current_line in text_list: 675 | stripped_len = len(terminal.StripAnsiText(current_line)) 676 | ansi_color_adds = len(current_line) - stripped_len 677 | # +2 for white space on either side. 678 | if stripped_len + 2 > col_size: 679 | raise TableError('String contains words that do not fit in column.') 680 | 681 | result.append(' %-*s' % (col_size - 1 + ansi_color_adds, current_line)) 682 | 683 | return result 684 | 685 | def FormattedTable( 686 | self, 687 | width=80, 688 | force_display=False, 689 | ml_delimiter=True, 690 | color=True, 691 | display_header=True, 692 | columns=None, 693 | ): 694 | """Returns whole table, with whitespace padding and row delimiters. 695 | 696 | Args: 697 | width: An int, the max width we want the table to fit in. 698 | force_display: A bool, if set to True will display table when the table 699 | can't be made to fit to the width. 700 | ml_delimiter: A bool, if set to False will not display the multi-line 701 | delimiter. 702 | color: A bool. If true, display any colours in row.colour. 703 | display_header: A bool. If true, display header. 704 | columns: A list of str, show only columns with these names. 705 | 706 | Returns: 707 | A string. The tabled output. 708 | 709 | Raises: 710 | TableError: Width too narrow to display table. 711 | """ 712 | 713 | def _FilteredCols(): 714 | """Returns list of column names to display.""" 715 | if not columns: 716 | return self._Header().values 717 | return [col for col in self._Header().values if col in columns] 718 | 719 | # Largest is the biggest data entry in a column. 720 | largest = {} 721 | # Smallest is the same as above but with linewrap i.e. largest unbroken 722 | # word in the data stream. 723 | smallest = {} 724 | # largest == smallest for a column with a single word of data. 725 | # Initialise largest and smallest for all columns. 726 | for key in _FilteredCols(): 727 | largest[key] = 0 728 | smallest[key] = 0 729 | 730 | # Find the largest and smallest values. 731 | # Include Title line in equation. 732 | # pylint: disable=E1103 733 | for row in self._table: 734 | for key, value in row.items(): 735 | if key not in _FilteredCols(): 736 | continue 737 | # Convert lists into a string. 738 | if isinstance(value, list): 739 | value = ', '.join(value) 740 | value = terminal.StripAnsiText(value) 741 | largest[key] = max(len(value), largest[key]) 742 | smallest[key] = max(self._SmallestColSize(value), smallest[key]) 743 | # pylint: enable=E1103 744 | 745 | min_total_width = 0 746 | multi_word = [] 747 | # Bump up the size of each column to include minimum pad. 748 | # Find all columns that can be wrapped (multi-line). 749 | # And the minimum width needed to display all columns (even if wrapped). 750 | for key in _FilteredCols(): 751 | # Each column is bracketed by a space on both sides. 752 | # So increase size required accordingly. 753 | largest[key] += 2 754 | smallest[key] += 2 755 | min_total_width += smallest[key] 756 | # If column contains data that 'could' be split over multiple lines. 757 | if largest[key] != smallest[key]: 758 | multi_word.append(key) 759 | 760 | # Check if we have enough space to display the table. 761 | if min_total_width > width and not force_display: 762 | raise TableError('Width too narrow to display table.') 763 | 764 | # We have some columns that may need wrapping over several lines. 765 | if multi_word: 766 | # Find how much space is left over for the wrapped columns to use. 767 | # Also find how much space we would need if they were not wrapped. 768 | # These are 'spare_width' and 'desired_width' respectively. 769 | desired_width = 0 770 | spare_width = width - min_total_width 771 | for key in multi_word: 772 | spare_width += smallest[key] 773 | desired_width += largest[key] 774 | 775 | # Scale up the space we give each wrapped column. 776 | # Proportional to its size relative to 'desired_width' for all columns. 777 | # Rinse and repeat if we changed the wrap list in this iteration. 778 | # Once done we will have a list of columns that definitely need wrapping. 779 | done = False 780 | while not done: 781 | done = True 782 | for key in multi_word: 783 | # If we scale past the desired width for this particular column, 784 | # then give it its desired width and remove it from the wrapped list. 785 | if largest[key] <= round( 786 | (largest[key] / float(desired_width)) * spare_width 787 | ): 788 | smallest[key] = largest[key] 789 | multi_word.remove(key) 790 | spare_width -= smallest[key] 791 | desired_width -= largest[key] 792 | done = False 793 | # If we scale below the minimum width for this particular column, 794 | # then leave it at its minimum and remove it from the wrapped list. 795 | elif smallest[key] >= round( 796 | (largest[key] / float(desired_width)) * spare_width 797 | ): 798 | multi_word.remove(key) 799 | spare_width -= smallest[key] 800 | desired_width -= largest[key] 801 | done = False 802 | 803 | # Repeat the scaling algorithm with the final wrap list. 804 | # This time we assign the extra column space by increasing 'smallest'. 805 | for key in multi_word: 806 | smallest[key] = int( 807 | round((largest[key] / float(desired_width)) * spare_width) 808 | ) 809 | 810 | total_width = 0 811 | row_count = 0 812 | result_dict = {} 813 | # Format the header lines and add to result_dict. 814 | # Find what the total width will be and use this for the ruled lines. 815 | # Find how many rows are needed for the most wrapped line (row_count). 816 | for key in _FilteredCols(): 817 | result_dict[key] = self._TextJustify(key, smallest[key]) 818 | if len(result_dict[key]) > row_count: 819 | row_count = len(result_dict[key]) 820 | total_width += smallest[key] 821 | 822 | # Store header in header_list, working down the wrapped rows. 823 | header_list = [] 824 | for row_idx in range(row_count): 825 | for key in _FilteredCols(): 826 | try: 827 | header_list.append(result_dict[key][row_idx]) 828 | except IndexError: 829 | # If no value than use whitespace of equal size. 830 | header_list.append(' ' * smallest[key]) 831 | header_list.append('\n') 832 | 833 | # Format and store the body lines 834 | result_dict = {} 835 | body_list = [] 836 | # We separate multi line rows with a single line delimiter. 837 | prev_muli_line = False 838 | # Unless it is the first line in which there is already the header line. 839 | first_line = True 840 | for row in self: 841 | row_count = 0 842 | for key, value in row.items(): 843 | if key not in _FilteredCols(): 844 | continue 845 | # Convert field contents to a string. 846 | if isinstance(value, list): 847 | value = ', '.join(value) 848 | # Store results in result_dict and take note of wrapped line count. 849 | result_dict[key] = self._TextJustify(value, smallest[key]) 850 | if len(result_dict[key]) > row_count: 851 | row_count = len(result_dict[key]) 852 | 853 | if row_count > 1: 854 | prev_muli_line = True 855 | # If current or prior line was multi-line then include delimiter. 856 | if not first_line and prev_muli_line and ml_delimiter: 857 | body_list.append('-' * total_width + '\n') 858 | if row_count == 1: 859 | # Our current line was not wrapped, so clear flag. 860 | prev_muli_line = False 861 | 862 | row_list = [] 863 | for row_idx in range(row_count): 864 | for key in _FilteredCols(): 865 | try: 866 | row_list.append(result_dict[key][row_idx]) 867 | except IndexError: 868 | # If no value than use whitespace of equal size. 869 | row_list.append(' ' * smallest[key]) 870 | row_list.append('\n') 871 | 872 | if color and row.color is not None: 873 | body_list.append( 874 | terminal.AnsiText(''.join(row_list)[:-1], command_list=row.color) 875 | ) 876 | body_list.append('\n') 877 | else: 878 | body_list.append(''.join(row_list)) 879 | 880 | first_line = False 881 | 882 | header = ''.join(header_list) + '=' * total_width 883 | if color and self._Header().color is not None: 884 | header = terminal.AnsiText(header, command_list=self._Header().color) 885 | # Add double line delimiter between header and main body. 886 | if display_header: 887 | return '%s\n%s' % (header, ''.join(body_list)) 888 | return '%s' % ''.join(body_list) 889 | 890 | def LabelValueTable(self, label_list=None): 891 | """Returns whole table as rows of name/value pairs. 892 | 893 | One (or more) column entries are used for the row prefix label. 894 | The remaining columns are each displayed as a row entry with the 895 | prefix labels appended. 896 | 897 | Use the first column as the label if label_list is None. 898 | 899 | Args: 900 | label_list: A list of prefix labels to use. 901 | 902 | Returns: 903 | Label/Value formatted table. 904 | 905 | Raises: 906 | TableError: If specified label is not a column header of the table. 907 | """ 908 | label_list = label_list or self._Header()[0] 909 | # Ensure all labels are valid. 910 | for label in label_list: 911 | if label not in self._Header(): 912 | raise TableError('Invalid label prefix: %s.' % label) 913 | 914 | sorted_list = [] 915 | for header in self._Header(): 916 | if header in label_list: 917 | sorted_list.append(header) 918 | 919 | label_str = '# LABEL %s\n' % '.'.join(sorted_list) 920 | 921 | body = [] 922 | for row in self: 923 | # Some row values are pulled into the label, stored in label_prefix. 924 | label_prefix = [] 925 | value_list = [] 926 | for key, value in row.items(): 927 | if key in sorted_list: 928 | # Set prefix. 929 | label_prefix.append(value) 930 | else: 931 | value_list.append('%s %s' % (key, value)) 932 | 933 | body.append( 934 | ''.join(['%s.%s\n' % ('.'.join(label_prefix), v) for v in value_list]) 935 | ) 936 | 937 | return '%s%s' % (label_str, ''.join(body)) 938 | 939 | table = property(_GetTable, _SetTable, doc='Whole table') 940 | row = property(_GetRow, _SetRow, doc='Current row') 941 | header = property(_Header, _SetHeader, doc='List of header entries.') 942 | row_index = property(_GetRowIndex, _SetRowIndex, doc='Current row.') 943 | size = property(_GetSize, doc='Number of rows in table.') 944 | 945 | def RowWith(self, column, value): 946 | """Retrieves the first non header row with the column of the given value. 947 | 948 | Args: 949 | column: str, the name of the column to check. 950 | value: str, The value of the column to check. 951 | 952 | Returns: 953 | A Row() of the first row found, None otherwise. 954 | 955 | Raises: 956 | IndexError: The specified column does not exist. 957 | """ 958 | for row in self._table[1:]: 959 | if row[column] == value: 960 | return row 961 | return None 962 | 963 | def AddColumn(self, column, default='', col_index=-1): 964 | """Appends a new column to the table. 965 | 966 | Args: 967 | column: A string, name of the column to add. 968 | default: Default value for entries. Defaults to ''. 969 | col_index: Integer index for where to insert new column. 970 | 971 | Raises: 972 | TableError: Column name already exists. 973 | """ 974 | if column in self.table: 975 | raise TableError('Column %r already in table.' % column) 976 | if col_index == -1: 977 | self._table[0][column] = column 978 | for i in range(1, len(self._table)): 979 | self._table[i][column] = default 980 | else: 981 | self._table[0].Insert(column, column, col_index) 982 | for i in range(1, len(self._table)): 983 | self._table[i].Insert(column, default, col_index) 984 | 985 | def Append(self, new_values): 986 | """Adds a new row (list) to the table. 987 | 988 | Args: 989 | new_values: Tuple, dict, or Row() of new values to append as a row. 990 | 991 | Raises: 992 | TableError: Supplied tuple not equal to table width. 993 | """ 994 | newrow = self.NewRow() 995 | newrow.values = new_values 996 | self._table.append(newrow) 997 | 998 | def NewRow(self, value=''): 999 | """Fetches a new, empty row, with headers populated. 1000 | 1001 | Args: 1002 | value: Initial value to set each row entry to. 1003 | 1004 | Returns: 1005 | A Row() object. 1006 | """ 1007 | newrow = self.row_class() 1008 | newrow.row = self.size + 1 1009 | newrow.table = self 1010 | headers = self._Header() 1011 | for header in headers: 1012 | newrow[header] = value 1013 | return newrow 1014 | 1015 | def CsvToTable(self, buf, header=True, separator=','): 1016 | """Parses buffer into tabular format. 1017 | 1018 | Strips off comments (preceded by '#'). 1019 | Optionally parses and indexes by first line (header). 1020 | 1021 | Args: 1022 | buf: String file buffer containing CSV data. 1023 | header: Is the first line of buffer a header. 1024 | separator: String that CSV is separated by. 1025 | 1026 | Returns: 1027 | int, the size of the table created. 1028 | 1029 | Raises: 1030 | TableError: A parsing error occurred. 1031 | """ 1032 | self.Reset() 1033 | 1034 | header_row = self.row_class() 1035 | header_length = 0 1036 | if header: 1037 | line = buf.readline() 1038 | header_str = '' 1039 | while not header_str: 1040 | if not isinstance(line, str): 1041 | line = line.decode('utf-8') 1042 | # Remove comments. 1043 | header_str = line.split('#')[0].strip() 1044 | if not header_str: 1045 | line = buf.readline() 1046 | 1047 | header_list = header_str.split(separator) 1048 | header_length = len(header_list) 1049 | 1050 | for entry in header_list: 1051 | entry = entry.strip() 1052 | if entry in header_row: 1053 | raise TableError('Duplicate header entry %r.' % entry) 1054 | 1055 | header_row[entry] = entry 1056 | header_row.row = 0 1057 | self._table[0] = header_row 1058 | 1059 | # xreadlines would be better but not supported by StringIO for testing. 1060 | for line in buf: 1061 | if not isinstance(line, str): 1062 | line = line.decode('utf-8') 1063 | # Support commented lines, provide '#' is first character of line. 1064 | if line.startswith('#'): 1065 | continue 1066 | 1067 | lst = line.split(separator) 1068 | lst = [l.strip() for l in lst] 1069 | if header and len(lst) != header_length: 1070 | # Silently drop illegal line entries 1071 | continue 1072 | if not header: 1073 | header_row = self.row_class() 1074 | header_length = len(lst) 1075 | header_row.values = dict( 1076 | zip(range(header_length), range(header_length)) 1077 | ) 1078 | self._table[0] = header_row 1079 | header = True 1080 | continue 1081 | 1082 | new_row = self.NewRow() 1083 | new_row.values = lst 1084 | header_row.row = self.size + 1 1085 | self._table.append(new_row) 1086 | 1087 | return self.size 1088 | 1089 | def index(self, name=None): # pylint: disable=invalid-name 1090 | """Returns index number of supplied column name. 1091 | 1092 | Args: 1093 | name: string of column name. 1094 | 1095 | Raises: 1096 | TableError: If name not found. 1097 | 1098 | Returns: 1099 | Index of the specified header entry. 1100 | """ 1101 | try: 1102 | return self.header.index(name) 1103 | except ValueError as exc: 1104 | raise TableError('Unknown index name %s.' % name) from exc 1105 | --------------------------------------------------------------------------------