├── .circleci └── config.yml ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── changelog.md ├── csrbuilder ├── __init__.py └── version.py ├── dev ├── __init__.py ├── _import.py ├── _pep425.py ├── _task.py ├── api_docs.py ├── build.py ├── ci-cleanup.py ├── ci-driver.py ├── ci.py ├── codecov.json ├── coverage.py ├── deps.py ├── lint.py ├── pyenv-install.py ├── python-install.py ├── release.py ├── tests.py └── version.py ├── docs ├── api.md └── readme.md ├── readme.md ├── requires ├── api_docs ├── ci ├── coverage ├── lint └── release ├── run.py ├── setup.py ├── tests ├── __init__.py └── test_csrbuilder.py └── tox.ini /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | python2.7: 4 | machine: 5 | image: ubuntu-2004:202101-01 6 | resource_class: arm.medium 7 | steps: 8 | - checkout 9 | - run: python run.py deps 10 | - run: python run.py ci-driver 11 | python3.9: 12 | machine: 13 | image: ubuntu-2004:202101-01 14 | resource_class: arm.medium 15 | steps: 16 | - checkout 17 | - run: python run.py deps 18 | - run: python3 run.py ci-driver 19 | workflows: 20 | version: 2 21 | arm64: 22 | jobs: 23 | - python2.7 24 | - python3.9 25 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | build-windows: 6 | name: Python ${{ matrix.python }} on windows-2019 ${{ matrix.arch }} 7 | runs-on: windows-2019 8 | strategy: 9 | matrix: 10 | python: 11 | - '3.11' 12 | # - 'pypy-3.7-v7.3.5' 13 | arch: 14 | - 'x86' 15 | - 'x64' 16 | exclude: 17 | - python: 'pypy-3.7-v7.3.5' 18 | arch: x86 19 | steps: 20 | - uses: actions/checkout@master 21 | - uses: actions/setup-python@v2 22 | with: 23 | python-version: ${{ matrix.python }} 24 | architecture: ${{ matrix.arch }} 25 | - name: Install dependencies 26 | run: python run.py deps 27 | - name: Run test suite 28 | run: python run.py ci-driver 29 | - name: Run test suite (Windows legacy API) 30 | run: python run.py ci-driver winlegacy 31 | 32 | build-windows-old: 33 | name: Python ${{ matrix.python }} on windows-2019 ${{ matrix.arch }} 34 | runs-on: windows-2019 35 | strategy: 36 | matrix: 37 | python: 38 | - '2.6' 39 | - '2.7' 40 | - '3.3' 41 | arch: 42 | - 'x86' 43 | - 'x64' 44 | steps: 45 | - uses: actions/checkout@master 46 | 47 | - name: Cache Python 48 | id: cache-python 49 | uses: actions/cache@v2 50 | with: 51 | path: ~/AppData/Local/Python${{ matrix.python }}-${{ matrix.arch }} 52 | key: windows-2019-python-${{ matrix.python }}-${{ matrix.arch }} 53 | 54 | - name: Install Python ${{ matrix.python }} 55 | run: python run.py python-install ${{ matrix.python }} ${{ matrix.arch }} | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append 56 | 57 | - name: Install dependencies 58 | run: python run.py deps 59 | - name: Run test suite 60 | run: python run.py ci-driver 61 | - name: Run test suite (Windows legacy API) 62 | run: python run.py ci-driver winlegacy 63 | 64 | build-mac: 65 | name: Python ${{ matrix.python }} on macos-13 66 | runs-on: macos-13 67 | strategy: 68 | matrix: 69 | python: 70 | - '3.11' 71 | steps: 72 | - uses: actions/checkout@master 73 | - uses: actions/setup-python@v2 74 | with: 75 | python-version: ${{ matrix.python }} 76 | architecture: x64 77 | - name: Install dependencies 78 | run: python run.py deps 79 | - name: Run test suite 80 | run: python run.py ci-driver 81 | - name: Run test suite (Mac cffi) 82 | run: python run.py ci-driver cffi 83 | - name: Run test suite (Mac OpenSSL) 84 | run: python run.py ci-driver openssl 85 | - name: Run test suite (Mac OpenSSL/cffi) 86 | run: python run.py ci-driver cffi openssl 87 | 88 | build-mac-legacy: 89 | name: Python ${{ matrix.python }} on macos-11 90 | runs-on: macos-11 91 | strategy: 92 | matrix: 93 | python: 94 | - '3.7' 95 | - '3.11' 96 | steps: 97 | - uses: actions/checkout@master 98 | - uses: actions/setup-python@v2 99 | with: 100 | python-version: ${{ matrix.python }} 101 | architecture: x64 102 | - name: Install dependencies 103 | run: python run.py deps 104 | - name: Run test suite 105 | run: python run.py ci-driver 106 | - name: Run test suite (Mac cffi) 107 | run: python run.py ci-driver cffi 108 | - name: Run test suite (Mac OpenSSL) 109 | run: python run.py ci-driver openssl 110 | - name: Run test suite (Mac OpenSSL/cffi) 111 | run: python run.py ci-driver cffi openssl 112 | 113 | build-mac-old: 114 | name: Python ${{ matrix.python }} on macos-11 115 | runs-on: macos-11 116 | strategy: 117 | matrix: 118 | python: 119 | - '2.6' 120 | - '2.7' 121 | - '3.3' 122 | env: 123 | PYTHONIOENCODING: 'utf-8:surrogateescape' 124 | steps: 125 | - uses: actions/checkout@master 126 | 127 | - name: Check pyenv 128 | id: check-pyenv 129 | uses: actions/cache@v2 130 | with: 131 | path: ~/.pyenv 132 | key: macos-11-${{ matrix.python }}-pyenv 133 | 134 | - name: Install Python ${{ matrix.python }} 135 | run: python run.py pyenv-install ${{ matrix.python }} >> $GITHUB_PATH 136 | 137 | - name: Install dependencies 138 | run: python run.py deps 139 | - name: Run test suite 140 | run: python run.py ci-driver 141 | - name: Run test suite (Mac cffi) 142 | run: python run.py ci-driver cffi 143 | - name: Run test suite (Mac OpenSSL) 144 | run: python run.py ci-driver openssl 145 | - name: Run test suite (Mac OpenSSL/cffi) 146 | run: python run.py ci-driver cffi openssl 147 | 148 | build-mac-openssl3: 149 | name: Python ${{ matrix.python }} on macos-11 with OpenSSL 3.0 150 | runs-on: macos-11 151 | strategy: 152 | matrix: 153 | python: 154 | - '3.6' 155 | - '3.11' 156 | steps: 157 | - uses: actions/checkout@master 158 | - uses: actions/setup-python@v2 159 | with: 160 | python-version: ${{ matrix.python }} 161 | architecture: x64 162 | - name: Install OpenSSL 3.0 163 | run: brew install openssl@3 164 | - name: Install dependencies 165 | run: python run.py deps 166 | - name: Run test suite (Mac OpenSSL 3.0) 167 | run: python run.py ci-driver openssl3 168 | - name: Run test suite (Mac OpenSSL 3.0/cffi) 169 | run: python run.py ci-driver cffi openssl3 170 | 171 | build-ubuntu: 172 | name: Python ${{ matrix.python }} on ubuntu-20.04 x64 173 | runs-on: ubuntu-20.04 174 | strategy: 175 | matrix: 176 | python: 177 | - '3.9' 178 | - '3.10' 179 | - '3.11' 180 | - 'pypy-3.7-v7.3.5' 181 | steps: 182 | - uses: actions/checkout@master 183 | - uses: actions/setup-python@v2 184 | with: 185 | python-version: ${{ matrix.python }} 186 | architecture: x64 187 | - name: Install dependencies 188 | run: python run.py deps 189 | - name: Run test suite 190 | run: python run.py ci-driver 191 | 192 | build-ubuntu-openssl3-py3: 193 | name: Python 3 on (Docker) ubuntu-22.04 x64 194 | runs-on: ubuntu-latest 195 | container: ubuntu:22.04 196 | steps: 197 | - uses: actions/checkout@master 198 | - name: Install Python and OpenSSL 199 | run: DEBIAN_FRONTEND=noninteractive apt-get update && apt-get install -y --no-install-recommends python3 python3-setuptools python-is-python3 openssl curl ca-certificates git 200 | - name: Install dependencies 201 | run: python run.py deps 202 | - name: Run test suite 203 | run: python run.py ci-driver 204 | - name: Run test suite (cffi) 205 | run: python run.py ci-driver cffi 206 | 207 | build-ubuntu-openssl3-py2: 208 | name: Python 2 on (Docker) ubuntu-22.04 x64 209 | runs-on: ubuntu-latest 210 | container: ubuntu:22.04 211 | steps: 212 | - uses: actions/checkout@master 213 | - name: Install Python and OpenSSL 214 | run: DEBIAN_FRONTEND=noninteractive apt-get update && apt-get install -y --no-install-recommends python2 python-setuptools openssl curl ca-certificates git 215 | - name: Install dependencies 216 | run: python2 run.py deps 217 | - name: Run test suite 218 | run: python2 run.py ci-driver 219 | - name: Run test suite (cffi) 220 | run: python2 run.py ci-driver cffi 221 | 222 | 223 | build-ubuntu-old: 224 | name: Python ${{ matrix.python }} on ubuntu-20.04 x64 225 | runs-on: ubuntu-20.04 226 | strategy: 227 | matrix: 228 | python: 229 | - '3.6' 230 | - '3.7' 231 | steps: 232 | - uses: actions/checkout@master 233 | - name: Setup deadsnakes/ppa 234 | run: sudo apt-add-repository ppa:deadsnakes/ppa 235 | - name: Update apt 236 | run: sudo apt-get update 237 | - name: Install Python ${{matrix.python}} 238 | run: sudo apt-get install python${{matrix.python}} python${{matrix.python}}-distutils 239 | - name: Install dependencies 240 | run: python${{matrix.python}} run.py deps 241 | - name: Run test suite 242 | run: python${{matrix.python}} run.py ci-driver 243 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info/ 2 | __pycache__/ 3 | .tox/ 4 | build/ 5 | dist/ 6 | tests/output/ 7 | *.pyc 8 | .coverage 9 | .DS_Store 10 | .python-version 11 | coverage.xml 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2018 Will Bond 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # changelog 2 | 3 | ## 0.10.1 4 | 5 | - Updated [asn1crypto](https://github.com/wbond/asn1crypto) dependency to 6 | `0.18.1`, [oscrypto](https://github.com/wbond/oscrypto) dependency to 7 | `0.16.1`. 8 | 9 | ## 0.10.0 10 | 11 | - Setting the `.ca` attribute to `None` now works properly 12 | - The extension request attribute is not added to the request if there are 13 | no extensions to request 14 | - Added explicit support for the TLS feature extension from RFC 7633 15 | 16 | ## 0.9.1 17 | 18 | - Package metadata updates 19 | 20 | ## 0.9.0 21 | 22 | - Initial release 23 | -------------------------------------------------------------------------------- /csrbuilder/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import inspect 5 | import re 6 | import sys 7 | import textwrap 8 | 9 | from asn1crypto import x509, keys, csr, pem 10 | from oscrypto import asymmetric 11 | 12 | from .version import __version__, __version_info__ 13 | 14 | if sys.version_info < (3,): 15 | int_types = (int, long) # noqa 16 | str_cls = unicode # noqa 17 | else: 18 | int_types = (int,) 19 | str_cls = str 20 | 21 | 22 | __all__ = [ 23 | '__version__', 24 | '__version_info__', 25 | 'CSRBuilder', 26 | 'pem_armor_csr', 27 | ] 28 | 29 | 30 | def _writer(func): 31 | """ 32 | Decorator for a custom writer, but a default reader 33 | """ 34 | 35 | name = func.__name__ 36 | return property(fget=lambda self: getattr(self, '_%s' % name), fset=func) 37 | 38 | 39 | def pem_armor_csr(certification_request): 40 | """ 41 | Encodes a CSR into PEM format 42 | 43 | :param certification_request: 44 | An asn1crypto.csr.CertificationRequest object of the CSR to armor. 45 | Typically this is obtained from CSRBuilder.build(). 46 | 47 | :return: 48 | A byte string of the PEM-encoded CSR 49 | """ 50 | 51 | if not isinstance(certification_request, csr.CertificationRequest): 52 | raise TypeError(_pretty_message( 53 | ''' 54 | certification_request must be an instance of 55 | asn1crypto.csr.CertificationRequest, not %s 56 | ''', 57 | _type_name(certification_request) 58 | )) 59 | 60 | return pem.armor( 61 | 'CERTIFICATE REQUEST', 62 | certification_request.dump() 63 | ) 64 | 65 | 66 | class CSRBuilder(object): 67 | 68 | _subject = None 69 | _subject_public_key = None 70 | _hash_algo = None 71 | _basic_constraints = None 72 | _subject_alt_name = None 73 | _key_usage = None 74 | _extended_key_usage = None 75 | _other_extensions = None 76 | 77 | _special_extensions = set([ 78 | 'basic_constraints', 79 | 'subject_alt_name', 80 | 'key_usage', 81 | 'extended_key_usage', 82 | ]) 83 | 84 | def __init__(self, subject, subject_public_key): 85 | """ 86 | Unless changed, CSRs will use SHA-256 for the signature 87 | 88 | :param subject: 89 | An asn1crypto.x509.Name object, or a dict - see the docstring 90 | for .subject for a list of valid options 91 | 92 | :param subject_public_key: 93 | An asn1crypto.keys.PublicKeyInfo object containing the public key 94 | the certificate is being requested for 95 | """ 96 | 97 | self.subject = subject 98 | self.subject_public_key = subject_public_key 99 | self.ca = False 100 | 101 | self._hash_algo = 'sha256' 102 | self._other_extensions = {} 103 | 104 | @_writer 105 | def subject(self, value): 106 | """ 107 | An asn1crypto.x509.Name object, or a dict with at least the 108 | following keys: 109 | 110 | - country_name 111 | - state_or_province_name 112 | - locality_name 113 | - organization_name 114 | - common_name 115 | 116 | Less common keys include: 117 | 118 | - organizational_unit_name 119 | - email_address 120 | - street_address 121 | - postal_code 122 | - business_category 123 | - incorporation_locality 124 | - incorporation_state_or_province 125 | - incorporation_country 126 | 127 | Uncommon keys include: 128 | 129 | - surname 130 | - title 131 | - serial_number 132 | - name 133 | - given_name 134 | - initials 135 | - generation_qualifier 136 | - dn_qualifier 137 | - pseudonym 138 | - domain_component 139 | 140 | All values should be unicode strings 141 | """ 142 | 143 | is_dict = isinstance(value, dict) 144 | if not isinstance(value, x509.Name) and not is_dict: 145 | raise TypeError(_pretty_message( 146 | ''' 147 | subject must be an instance of asn1crypto.x509.Name or a dict, 148 | not %s 149 | ''', 150 | _type_name(value) 151 | )) 152 | 153 | if is_dict: 154 | value = x509.Name.build(value) 155 | 156 | self._subject = value 157 | 158 | @_writer 159 | def subject_public_key(self, value): 160 | """ 161 | An asn1crypto.keys.PublicKeyInfo or oscrypto.asymmetric.PublicKey 162 | object of the subject's public key. 163 | """ 164 | 165 | is_oscrypto = isinstance(value, asymmetric.PublicKey) 166 | if not isinstance(value, keys.PublicKeyInfo) and not is_oscrypto: 167 | raise TypeError(_pretty_message( 168 | ''' 169 | subject_public_key must be an instance of 170 | asn1crypto.keys.PublicKeyInfo or oscrypto.asymmetric.PublicKey, 171 | not %s 172 | ''', 173 | _type_name(value) 174 | )) 175 | 176 | if is_oscrypto: 177 | value = value.asn1 178 | 179 | self._subject_public_key = value 180 | 181 | self._key_identifier = self._subject_public_key.sha1 182 | self._authority_key_identifier = None 183 | 184 | @_writer 185 | def hash_algo(self, value): 186 | """ 187 | A unicode string of the hash algorithm to use when signing the 188 | request - "sha1" (not recommended), "sha256" or "sha512" 189 | """ 190 | 191 | if value not in set(['sha1', 'sha256', 'sha512']): 192 | raise ValueError(_pretty_message( 193 | ''' 194 | hash_algo must be one of "sha1", "sha256", "sha512", not %s 195 | ''', 196 | repr(value) 197 | )) 198 | 199 | self._hash_algo = value 200 | 201 | @property 202 | def ca(self): 203 | """ 204 | None or a bool - if the request is for a CA cert. None indicates no 205 | basic constraints extension request. 206 | """ 207 | 208 | if self._basic_constraints is None: 209 | return None 210 | 211 | return self._basic_constraints['ca'].native 212 | 213 | @ca.setter 214 | def ca(self, value): 215 | if value is None: 216 | self._basic_constraints = None 217 | return 218 | 219 | self._basic_constraints = x509.BasicConstraints({'ca': bool(value)}) 220 | 221 | if value: 222 | self._key_usage = x509.KeyUsage(set(['key_cert_sign', 'crl_sign'])) 223 | self._extended_key_usage = x509.ExtKeyUsageSyntax(['ocsp_signing']) 224 | else: 225 | self._key_usage = x509.KeyUsage(set(['digital_signature', 'key_encipherment'])) 226 | self._extended_key_usage = x509.ExtKeyUsageSyntax(['server_auth', 'client_auth']) 227 | 228 | @property 229 | def subject_alt_domains(self): 230 | """ 231 | A list of unicode strings of all domains in the subject alt name 232 | extension request. Empty list indicates no subject alt name extension 233 | request. 234 | """ 235 | 236 | return self._get_subject_alt('dns_name') 237 | 238 | @subject_alt_domains.setter 239 | def subject_alt_domains(self, value): 240 | self._set_subject_alt('dns_name', value) 241 | 242 | @property 243 | def subject_alt_ips(self): 244 | """ 245 | A list of unicode strings of all IPs in the subject alt name extension 246 | request. Empty list indicates no subject alt name extension request. 247 | """ 248 | 249 | return self._get_subject_alt('ip_address') 250 | 251 | @subject_alt_ips.setter 252 | def subject_alt_ips(self, value): 253 | self._set_subject_alt('ip_address', value) 254 | 255 | def _get_subject_alt(self, name): 256 | """ 257 | Returns the native value for each value in the subject alt name 258 | extension reqiest that is an asn1crypto.x509.GeneralName of the type 259 | specified by the name param 260 | 261 | :param name: 262 | A unicode string use to filter the x509.GeneralName objects by - 263 | is the choice name x509.GeneralName 264 | 265 | :return: 266 | A list of unicode strings. Empty list indicates no subject alt 267 | name extension request. 268 | """ 269 | 270 | if self._subject_alt_name is None: 271 | return [] 272 | 273 | output = [] 274 | for general_name in self._subject_alt_name: 275 | if general_name.name == name: 276 | output.append(general_name.native) 277 | return output 278 | 279 | def _set_subject_alt(self, name, values): 280 | """ 281 | Replaces all existing asn1crypto.x509.GeneralName objects of the choice 282 | represented by the name parameter with the values 283 | 284 | :param name: 285 | A unicode string of the choice name of the x509.GeneralName object 286 | 287 | :param values: 288 | A list of unicode strings to use as the values for the new 289 | x509.GeneralName objects 290 | """ 291 | 292 | if self._subject_alt_name is not None: 293 | filtered_general_names = [] 294 | for general_name in self._subject_alt_name: 295 | if general_name.name != name: 296 | filtered_general_names.append(general_name) 297 | self._subject_alt_name = x509.GeneralNames(filtered_general_names) 298 | else: 299 | self._subject_alt_name = x509.GeneralNames() 300 | 301 | if values is not None: 302 | for value in values: 303 | new_general_name = x509.GeneralName(name=name, value=value) 304 | self._subject_alt_name.append(new_general_name) 305 | 306 | if len(self._subject_alt_name) == 0: 307 | self._subject_alt_name = None 308 | 309 | @property 310 | def key_usage(self): 311 | """ 312 | A set of unicode strings representing the allowed usage of the key. 313 | Empty set indicates no key usage extension request. 314 | """ 315 | 316 | if self._key_usage is None: 317 | return set() 318 | 319 | return self._key_usage.native 320 | 321 | @key_usage.setter 322 | def key_usage(self, value): 323 | if not isinstance(value, set) and value is not None: 324 | raise TypeError(_pretty_message( 325 | ''' 326 | key_usage must be an instance of set, not %s 327 | ''', 328 | _type_name(value) 329 | )) 330 | 331 | if value == set() or value is None: 332 | self._key_usage = None 333 | else: 334 | self._key_usage = x509.KeyUsage(value) 335 | 336 | @property 337 | def extended_key_usage(self): 338 | """ 339 | A set of unicode strings representing the allowed usage of the key from 340 | the extended key usage extension. Empty set indicates no extended key 341 | usage extension request. 342 | """ 343 | 344 | if self._extended_key_usage is None: 345 | return set() 346 | 347 | return set(self._extended_key_usage.native) 348 | 349 | @extended_key_usage.setter 350 | def extended_key_usage(self, value): 351 | if not isinstance(value, set) and value is not None: 352 | raise TypeError(_pretty_message( 353 | ''' 354 | extended_key_usage must be an instance of set, not %s 355 | ''', 356 | _type_name(value) 357 | )) 358 | 359 | if value == set() or value is None: 360 | self._extended_key_usage = None 361 | else: 362 | self._extended_key_usage = x509.ExtKeyUsageSyntax(list(value)) 363 | 364 | def set_extension(self, name, value): 365 | """ 366 | Sets the value for an extension using a fully constructed Asn1Value 367 | object from asn1crypto. Normally this should not be needed, and the 368 | convenience attributes should be sufficient. 369 | 370 | See the definition of asn1crypto.x509.Extension to determine the 371 | appropriate object type for a given extension. Extensions are marked 372 | as critical when RFC5280 or RFC6960 indicate so. If an extension is 373 | validly marked as critical or not (such as certificate policies and 374 | extended key usage), this class will mark it as non-critical. 375 | 376 | :param name: 377 | A unicode string of an extension id name from 378 | asn1crypto.x509.ExtensionId 379 | 380 | :param value: 381 | A value object per the specs defined by asn1crypto.x509.Extension 382 | """ 383 | 384 | extension = x509.Extension({ 385 | 'extn_id': name 386 | }) 387 | # We use native here to convert OIDs to meaningful names 388 | name = extension['extn_id'].native 389 | 390 | spec = extension.spec('extn_value') 391 | 392 | if not isinstance(value, spec) and value is not None: 393 | raise TypeError(_pretty_message( 394 | ''' 395 | value must be an instance of %s, not %s 396 | ''', 397 | _type_name(spec), 398 | _type_name(value) 399 | )) 400 | 401 | if name in self._special_extensions: 402 | setattr(self, '_%s' % name, value) 403 | else: 404 | if value is None: 405 | if name in self._other_extensions: 406 | del self._other_extensions[name] 407 | else: 408 | self._other_extensions[name] = value 409 | 410 | def _determine_critical(self, name): 411 | """ 412 | :return: 413 | A boolean indicating the correct value of the critical flag for 414 | an extension, based on information from RFC5280 and RFC 6960. The 415 | correct value is based on the terminology SHOULD or MUST. 416 | """ 417 | 418 | if name == 'subject_alt_name': 419 | return len(self._subject) == 0 420 | 421 | if name == 'basic_constraints': 422 | return self.ca is True 423 | 424 | return { 425 | 'subject_directory_attributes': False, 426 | 'key_usage': True, 427 | 'issuer_alt_name': False, 428 | 'name_constraints': True, 429 | # Based on example EV certificates, non-CA certs have this marked 430 | # as non-critical, most likely because existing browsers don't 431 | # seem to support policies or name constraints 432 | 'certificate_policies': False, 433 | 'policy_mappings': True, 434 | 'policy_constraints': True, 435 | 'extended_key_usage': False, 436 | 'inhibit_any_policy': True, 437 | 'subject_information_access': False, 438 | 'tls_feature': False, 439 | 'ocsp_no_check': False, 440 | }.get(name, False) 441 | 442 | def build(self, signing_private_key): 443 | """ 444 | Validates the certificate information, constructs an X.509 certificate 445 | and then signs it 446 | 447 | :param signing_private_key: 448 | An asn1crypto.keys.PrivateKeyInfo or oscrypto.asymmetric.PrivateKey 449 | object for the private key to sign the request with. This should be 450 | the private key that matches the public key. 451 | 452 | :return: 453 | An asn1crypto.csr.CertificationRequest object of the request 454 | """ 455 | 456 | is_oscrypto = isinstance(signing_private_key, asymmetric.PrivateKey) 457 | if not isinstance(signing_private_key, keys.PrivateKeyInfo) and not is_oscrypto: 458 | raise TypeError(_pretty_message( 459 | ''' 460 | signing_private_key must be an instance of 461 | asn1crypto.keys.PrivateKeyInfo or 462 | oscrypto.asymmetric.PrivateKey, not %s 463 | ''', 464 | _type_name(signing_private_key) 465 | )) 466 | 467 | signature_algo = signing_private_key.algorithm 468 | if signature_algo == 'ec': 469 | signature_algo = 'ecdsa' 470 | 471 | signature_algorithm_id = '%s_%s' % (self._hash_algo, signature_algo) 472 | 473 | def _make_extension(name, value): 474 | return { 475 | 'extn_id': name, 476 | 'critical': self._determine_critical(name), 477 | 'extn_value': value 478 | } 479 | 480 | extensions = [] 481 | for name in sorted(self._special_extensions): 482 | value = getattr(self, '_%s' % name) 483 | if value is not None: 484 | extensions.append(_make_extension(name, value)) 485 | 486 | for name in sorted(self._other_extensions.keys()): 487 | extensions.append(_make_extension(name, self._other_extensions[name])) 488 | 489 | attributes = [] 490 | if extensions: 491 | attributes.append({ 492 | 'type': 'extension_request', 493 | 'values': [extensions] 494 | }) 495 | 496 | certification_request_info = csr.CertificationRequestInfo({ 497 | 'version': 'v1', 498 | 'subject': self._subject, 499 | 'subject_pk_info': self._subject_public_key, 500 | 'attributes': attributes 501 | }) 502 | 503 | if signing_private_key.algorithm == 'rsa': 504 | sign_func = asymmetric.rsa_pkcs1v15_sign 505 | elif signing_private_key.algorithm == 'dsa': 506 | sign_func = asymmetric.dsa_sign 507 | elif signing_private_key.algorithm == 'ec': 508 | sign_func = asymmetric.ecdsa_sign 509 | 510 | if not is_oscrypto: 511 | signing_private_key = asymmetric.load_private_key(signing_private_key) 512 | signature = sign_func(signing_private_key, certification_request_info.dump(), self._hash_algo) 513 | 514 | return csr.CertificationRequest({ 515 | 'certification_request_info': certification_request_info, 516 | 'signature_algorithm': { 517 | 'algorithm': signature_algorithm_id, 518 | }, 519 | 'signature': signature 520 | }) 521 | 522 | 523 | def _pretty_message(string, *params): 524 | """ 525 | Takes a multi-line string and does the following: 526 | 527 | - dedents 528 | - converts newlines with text before and after into a single line 529 | - strips leading and trailing whitespace 530 | 531 | :param string: 532 | The string to format 533 | 534 | :param *params: 535 | Params to interpolate into the string 536 | 537 | :return: 538 | The formatted string 539 | """ 540 | 541 | output = textwrap.dedent(string) 542 | 543 | # Unwrap lines, taking into account bulleted lists, ordered lists and 544 | # underlines consisting of = signs 545 | if output.find('\n') != -1: 546 | output = re.sub('(?<=\\S)\n(?=[^ \n\t\\d\\*\\-=])', ' ', output) 547 | 548 | if params: 549 | output = output % params 550 | 551 | output = output.strip() 552 | 553 | return output 554 | 555 | 556 | def _type_name(value): 557 | """ 558 | :param value: 559 | A value to get the object name of 560 | 561 | :return: 562 | A unicode string of the object name 563 | """ 564 | 565 | if inspect.isclass(value): 566 | cls = value 567 | else: 568 | cls = value.__class__ 569 | if cls.__module__ in set(['builtins', '__builtin__']): 570 | return cls.__name__ 571 | return '%s.%s' % (cls.__module__, cls.__name__) 572 | -------------------------------------------------------------------------------- /csrbuilder/version.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | 5 | __version__ = '0.10.1' 6 | __version_info__ = (0, 10, 1) 7 | -------------------------------------------------------------------------------- /dev/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import os 5 | 6 | 7 | package_name = "csrbuilder" 8 | 9 | other_packages = [] 10 | 11 | task_keyword_args = [] 12 | 13 | requires_oscrypto = True 14 | has_tests_package = False 15 | 16 | package_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) 17 | build_root = os.path.abspath(os.path.join(package_root, '..')) 18 | 19 | md_source_map = { 20 | 'docs/api.md': ['csrbuilder/__init__.py'], 21 | } 22 | 23 | definition_replacements = {} 24 | -------------------------------------------------------------------------------- /dev/_import.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import sys 5 | import os 6 | 7 | from . import build_root, package_name, package_root 8 | 9 | if sys.version_info < (3, 5): 10 | import imp 11 | else: 12 | import importlib 13 | import importlib.abc 14 | import importlib.util 15 | 16 | 17 | if sys.version_info < (3,): 18 | getcwd = os.getcwdu 19 | else: 20 | getcwd = os.getcwd 21 | 22 | 23 | if sys.version_info >= (3, 5): 24 | class ModCryptoMetaFinder(importlib.abc.MetaPathFinder): 25 | def setup(self): 26 | self.modules = {} 27 | sys.meta_path.insert(0, self) 28 | 29 | def add_module(self, package_name, package_path): 30 | if package_name not in self.modules: 31 | self.modules[package_name] = package_path 32 | 33 | def find_spec(self, fullname, path, target=None): 34 | name_parts = fullname.split('.') 35 | if name_parts[0] not in self.modules: 36 | return None 37 | 38 | package = name_parts[0] 39 | package_path = self.modules[package] 40 | 41 | fullpath = os.path.join(package_path, *name_parts[1:]) 42 | 43 | if os.path.isdir(fullpath): 44 | filename = os.path.join(fullpath, "__init__.py") 45 | submodule_locations = [fullpath] 46 | else: 47 | filename = fullpath + ".py" 48 | submodule_locations = None 49 | 50 | if not os.path.exists(filename): 51 | return None 52 | 53 | return importlib.util.spec_from_file_location( 54 | fullname, 55 | filename, 56 | loader=None, 57 | submodule_search_locations=submodule_locations 58 | ) 59 | 60 | CUSTOM_FINDER = ModCryptoMetaFinder() 61 | CUSTOM_FINDER.setup() 62 | 63 | 64 | def _import_from(mod, path, mod_dir=None, allow_error=False): 65 | """ 66 | Imports a module from a specific path 67 | 68 | :param mod: 69 | A unicode string of the module name 70 | 71 | :param path: 72 | A unicode string to the directory containing the module 73 | 74 | :param mod_dir: 75 | If the sub directory of "path" is different than the "mod" name, 76 | pass the sub directory as a unicode string 77 | 78 | :param allow_error: 79 | If an ImportError should be raised when the module can't be imported 80 | 81 | :return: 82 | None if not loaded, otherwise the module 83 | """ 84 | 85 | if mod in sys.modules: 86 | return sys.modules[mod] 87 | 88 | if mod_dir is None: 89 | full_mod = mod 90 | else: 91 | full_mod = mod_dir.replace(os.sep, '.') 92 | 93 | if mod_dir is None: 94 | mod_dir = mod.replace('.', os.sep) 95 | 96 | if not os.path.exists(path): 97 | return None 98 | 99 | source_path = os.path.join(path, mod_dir, '__init__.py') 100 | if not os.path.exists(source_path): 101 | source_path = os.path.join(path, mod_dir + '.py') 102 | 103 | if not os.path.exists(source_path): 104 | return None 105 | 106 | if os.sep in mod_dir: 107 | append, mod_dir = mod_dir.rsplit(os.sep, 1) 108 | path = os.path.join(path, append) 109 | 110 | try: 111 | if sys.version_info < (3, 5): 112 | mod_info = imp.find_module(mod_dir, [path]) 113 | return imp.load_module(mod, *mod_info) 114 | 115 | else: 116 | package = mod.split('.', 1)[0] 117 | package_dir = full_mod.split('.', 1)[0] 118 | package_path = os.path.join(path, package_dir) 119 | CUSTOM_FINDER.add_module(package, package_path) 120 | 121 | return importlib.import_module(mod) 122 | 123 | except ImportError: 124 | if allow_error: 125 | raise 126 | return None 127 | 128 | 129 | def _preload(require_oscrypto, print_info): 130 | """ 131 | Preloads asn1crypto and optionally oscrypto from a local source checkout, 132 | or from a normal install 133 | 134 | :param require_oscrypto: 135 | A bool if oscrypto needs to be preloaded 136 | 137 | :param print_info: 138 | A bool if info about asn1crypto and oscrypto should be printed 139 | """ 140 | 141 | if print_info: 142 | print('Working dir: ' + getcwd()) 143 | print('Python ' + sys.version.replace('\n', '')) 144 | 145 | asn1crypto = None 146 | oscrypto = None 147 | 148 | if require_oscrypto: 149 | # Some CI services don't use the package name for the dir 150 | if package_name == 'oscrypto': 151 | oscrypto_dir = package_root 152 | else: 153 | oscrypto_dir = os.path.join(build_root, 'oscrypto') 154 | oscrypto_tests = None 155 | if os.path.exists(oscrypto_dir): 156 | oscrypto_tests = _import_from('oscrypto_tests', oscrypto_dir, 'tests') 157 | if oscrypto_tests is None: 158 | import oscrypto_tests 159 | asn1crypto, oscrypto = oscrypto_tests.local_oscrypto() 160 | 161 | else: 162 | if package_name == 'asn1crypto': 163 | asn1crypto_dir = package_root 164 | else: 165 | asn1crypto_dir = os.path.join(build_root, 'asn1crypto') 166 | if os.path.exists(asn1crypto_dir): 167 | asn1crypto = _import_from('asn1crypto', asn1crypto_dir) 168 | if asn1crypto is None: 169 | import asn1crypto 170 | 171 | if print_info: 172 | print( 173 | '\nasn1crypto: %s, %s' % ( 174 | asn1crypto.__version__, 175 | os.path.dirname(asn1crypto.__file__) 176 | ) 177 | ) 178 | if require_oscrypto: 179 | backend = oscrypto.backend() 180 | if backend == 'openssl': 181 | from oscrypto._openssl._libcrypto import libcrypto_version 182 | backend = '%s (%s)' % (backend, libcrypto_version) 183 | 184 | print( 185 | 'oscrypto: %s, %s backend, %s' % ( 186 | oscrypto.__version__, 187 | backend, 188 | os.path.dirname(oscrypto.__file__) 189 | ) 190 | ) 191 | -------------------------------------------------------------------------------- /dev/_pep425.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | """ 4 | This file was originally derived from 5 | https://github.com/pypa/pip/blob/3e713708088aedb1cde32f3c94333d6e29aaf86e/src/pip/_internal/pep425tags.py 6 | 7 | The following license covers that code: 8 | 9 | Copyright (c) 2008-2018 The pip developers (see AUTHORS.txt file) 10 | 11 | Permission is hereby granted, free of charge, to any person obtaining 12 | a copy of this software and associated documentation files (the 13 | "Software"), to deal in the Software without restriction, including 14 | without limitation the rights to use, copy, modify, merge, publish, 15 | distribute, sublicense, and/or sell copies of the Software, and to 16 | permit persons to whom the Software is furnished to do so, subject to 17 | the following conditions: 18 | 19 | The above copyright notice and this permission notice shall be 20 | included in all copies or substantial portions of the Software. 21 | 22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 23 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 24 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 25 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 26 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 27 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 28 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 29 | """ 30 | 31 | from __future__ import unicode_literals, division, absolute_import, print_function 32 | 33 | import sys 34 | import os 35 | import ctypes 36 | import re 37 | import platform 38 | 39 | if sys.version_info >= (2, 7): 40 | import sysconfig 41 | 42 | if sys.version_info < (3,): 43 | str_cls = unicode # noqa 44 | else: 45 | str_cls = str 46 | 47 | 48 | def _pep425_implementation(): 49 | """ 50 | :return: 51 | A 2 character unicode string of the implementation - 'cp' for cpython 52 | or 'pp' for PyPy 53 | """ 54 | 55 | return 'pp' if hasattr(sys, 'pypy_version_info') else 'cp' 56 | 57 | 58 | def _pep425_version(): 59 | """ 60 | :return: 61 | A tuple of integers representing the Python version number 62 | """ 63 | 64 | if hasattr(sys, 'pypy_version_info'): 65 | return (sys.version_info[0], sys.pypy_version_info.major, 66 | sys.pypy_version_info.minor) 67 | else: 68 | return (sys.version_info[0], sys.version_info[1]) 69 | 70 | 71 | def _pep425_supports_manylinux(): 72 | """ 73 | :return: 74 | A boolean indicating if the machine can use manylinux1 packages 75 | """ 76 | 77 | try: 78 | import _manylinux 79 | return bool(_manylinux.manylinux1_compatible) 80 | except (ImportError, AttributeError): 81 | pass 82 | 83 | # Check for glibc 2.5 84 | try: 85 | proc = ctypes.CDLL(None) 86 | gnu_get_libc_version = proc.gnu_get_libc_version 87 | gnu_get_libc_version.restype = ctypes.c_char_p 88 | 89 | ver = gnu_get_libc_version() 90 | if not isinstance(ver, str_cls): 91 | ver = ver.decode('ascii') 92 | match = re.match(r'(\d+)\.(\d+)', ver) 93 | return match and match.group(1) == '2' and int(match.group(2)) >= 5 94 | 95 | except (AttributeError): 96 | return False 97 | 98 | 99 | def _pep425_get_abi(): 100 | """ 101 | :return: 102 | A unicode string of the system abi. Will be something like: "cp27m", 103 | "cp33m", etc. 104 | """ 105 | 106 | try: 107 | soabi = sysconfig.get_config_var('SOABI') 108 | if soabi: 109 | if soabi.startswith('cpython-'): 110 | return 'cp%s' % soabi.split('-')[1] 111 | return soabi.replace('.', '_').replace('-', '_') 112 | except (IOError, NameError): 113 | pass 114 | 115 | impl = _pep425_implementation() 116 | suffix = '' 117 | if impl == 'cp': 118 | suffix += 'm' 119 | if sys.maxunicode == 0x10ffff and sys.version_info < (3, 3): 120 | suffix += 'u' 121 | return '%s%s%s' % (impl, ''.join(map(str_cls, _pep425_version())), suffix) 122 | 123 | 124 | def _pep425tags(): 125 | """ 126 | :return: 127 | A list of 3-element tuples with unicode strings or None: 128 | [0] implementation tag - cp33, pp27, cp26, py2, py2.py3 129 | [1] abi tag - cp26m, None 130 | [2] arch tag - linux_x86_64, macosx_10_10_x85_64, etc 131 | """ 132 | 133 | tags = [] 134 | 135 | versions = [] 136 | version_info = _pep425_version() 137 | major = version_info[:-1] 138 | for minor in range(version_info[-1], -1, -1): 139 | versions.append(''.join(map(str, major + (minor,)))) 140 | 141 | impl = _pep425_implementation() 142 | 143 | abis = [] 144 | abi = _pep425_get_abi() 145 | if abi: 146 | abis.append(abi) 147 | abi3 = _pep425_implementation() == 'cp' and sys.version_info >= (3,) 148 | if abi3: 149 | abis.append('abi3') 150 | abis.append('none') 151 | 152 | if sys.platform == 'darwin': 153 | plat_ver = platform.mac_ver() 154 | ver_parts = plat_ver[0].split('.') 155 | minor = int(ver_parts[1]) 156 | arch = plat_ver[2] 157 | if sys.maxsize == 2147483647: 158 | arch = 'i386' 159 | arches = [] 160 | while minor > 5: 161 | arches.append('macosx_10_%s_%s' % (minor, arch)) 162 | arches.append('macosx_10_%s_intel' % (minor,)) 163 | arches.append('macosx_10_%s_universal' % (minor,)) 164 | minor -= 1 165 | else: 166 | if sys.platform == 'win32': 167 | if 'amd64' in sys.version.lower(): 168 | arches = ['win_amd64'] 169 | else: 170 | arches = [sys.platform] 171 | elif hasattr(os, 'uname'): 172 | (plat, _, _, _, machine) = os.uname() 173 | plat = plat.lower().replace('/', '') 174 | machine.replace(' ', '_').replace('/', '_') 175 | if plat == 'linux' and sys.maxsize == 2147483647 and 'arm' not in machine: 176 | machine = 'i686' 177 | arch = '%s_%s' % (plat, machine) 178 | if _pep425_supports_manylinux(): 179 | arches = [arch.replace('linux', 'manylinux1'), arch] 180 | else: 181 | arches = [arch] 182 | 183 | for abi in abis: 184 | for arch in arches: 185 | tags.append(('%s%s' % (impl, versions[0]), abi, arch)) 186 | 187 | if abi3: 188 | for version in versions[1:]: 189 | for arch in arches: 190 | tags.append(('%s%s' % (impl, version), 'abi3', arch)) 191 | 192 | for arch in arches: 193 | tags.append(('py%s' % (versions[0][0]), 'none', arch)) 194 | 195 | tags.append(('%s%s' % (impl, versions[0]), 'none', 'any')) 196 | tags.append(('%s%s' % (impl, versions[0][0]), 'none', 'any')) 197 | 198 | for i, version in enumerate(versions): 199 | tags.append(('py%s' % (version,), 'none', 'any')) 200 | if i == 0: 201 | tags.append(('py%s' % (version[0]), 'none', 'any')) 202 | 203 | tags.append(('py2.py3', 'none', 'any')) 204 | 205 | return tags 206 | -------------------------------------------------------------------------------- /dev/_task.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import ast 5 | import _ast 6 | import os 7 | import sys 8 | 9 | from . import package_root, task_keyword_args 10 | from ._import import _import_from 11 | 12 | 13 | if sys.version_info < (3,): 14 | byte_cls = str 15 | else: 16 | byte_cls = bytes 17 | 18 | 19 | def _list_tasks(): 20 | """ 21 | Fetches a list of all valid tasks that may be run, and the args they 22 | accept. Does not actually import the task module to prevent errors if a 23 | user does not have the dependencies installed for every task. 24 | 25 | :return: 26 | A list of 2-element tuples: 27 | 0: a unicode string of the task name 28 | 1: a list of dicts containing the parameter definitions 29 | """ 30 | 31 | out = [] 32 | dev_path = os.path.join(package_root, 'dev') 33 | for fname in sorted(os.listdir(dev_path)): 34 | if fname.startswith('.') or fname.startswith('_'): 35 | continue 36 | if not fname.endswith('.py'): 37 | continue 38 | name = fname[:-3] 39 | args = () 40 | 41 | full_path = os.path.join(package_root, 'dev', fname) 42 | with open(full_path, 'rb') as f: 43 | full_code = f.read() 44 | if sys.version_info >= (3,): 45 | full_code = full_code.decode('utf-8') 46 | 47 | task_node = ast.parse(full_code, filename=full_path) 48 | for node in ast.iter_child_nodes(task_node): 49 | if isinstance(node, _ast.Assign): 50 | if len(node.targets) == 1 \ 51 | and isinstance(node.targets[0], _ast.Name) \ 52 | and node.targets[0].id == 'run_args': 53 | args = ast.literal_eval(node.value) 54 | break 55 | 56 | out.append((name, args)) 57 | return out 58 | 59 | 60 | def show_usage(): 61 | """ 62 | Prints to stderr the valid options for invoking tasks 63 | """ 64 | 65 | valid_tasks = [] 66 | for task in _list_tasks(): 67 | usage = task[0] 68 | for run_arg in task[1]: 69 | usage += ' ' 70 | name = run_arg.get('name', '') 71 | if run_arg.get('required', False): 72 | usage += '{%s}' % name 73 | else: 74 | usage += '[%s]' % name 75 | valid_tasks.append(usage) 76 | 77 | out = 'Usage: run.py' 78 | for karg in task_keyword_args: 79 | out += ' [%s=%s]' % (karg['name'], karg['placeholder']) 80 | out += ' (%s)' % ' | '.join(valid_tasks) 81 | 82 | print(out, file=sys.stderr) 83 | sys.exit(1) 84 | 85 | 86 | def _get_arg(num): 87 | """ 88 | :return: 89 | A unicode string of the requested command line arg 90 | """ 91 | 92 | if len(sys.argv) < num + 1: 93 | return None 94 | arg = sys.argv[num] 95 | if isinstance(arg, byte_cls): 96 | arg = arg.decode('utf-8') 97 | return arg 98 | 99 | 100 | def run_task(): 101 | """ 102 | Parses the command line args, invoking the requested task 103 | """ 104 | 105 | arg_num = 1 106 | task = None 107 | args = [] 108 | kwargs = {} 109 | 110 | # We look for the task name, processing any global task keyword args 111 | # by setting the appropriate env var 112 | while True: 113 | val = _get_arg(arg_num) 114 | if val is None: 115 | break 116 | 117 | next_arg = False 118 | for karg in task_keyword_args: 119 | if val.startswith(karg['name'] + '='): 120 | os.environ[karg['env_var']] = val[len(karg['name']) + 1:] 121 | next_arg = True 122 | break 123 | 124 | if next_arg: 125 | arg_num += 1 126 | continue 127 | 128 | task = val 129 | break 130 | 131 | if task is None: 132 | show_usage() 133 | 134 | task_mod = _import_from('dev.%s' % task, package_root, allow_error=True) 135 | if task_mod is None: 136 | show_usage() 137 | 138 | run_args = task_mod.__dict__.get('run_args', []) 139 | max_args = arg_num + 1 + len(run_args) 140 | 141 | if len(sys.argv) > max_args: 142 | show_usage() 143 | 144 | for i, run_arg in enumerate(run_args): 145 | val = _get_arg(arg_num + 1 + i) 146 | if val is None: 147 | if run_arg.get('required', False): 148 | show_usage() 149 | break 150 | 151 | if run_arg.get('cast') == 'int' and val.isdigit(): 152 | val = int(val) 153 | 154 | kwarg = run_arg.get('kwarg') 155 | if kwarg: 156 | kwargs[kwarg] = val 157 | else: 158 | args.append(val) 159 | 160 | run = task_mod.__dict__.get('run') 161 | 162 | result = run(*args, **kwargs) 163 | sys.exit(int(not result)) 164 | -------------------------------------------------------------------------------- /dev/api_docs.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import ast 5 | import _ast 6 | import os 7 | import re 8 | import sys 9 | import textwrap 10 | 11 | import commonmark 12 | from collections import OrderedDict 13 | 14 | from . import package_name, package_root, md_source_map, definition_replacements 15 | 16 | 17 | if hasattr(commonmark, 'DocParser'): 18 | raise EnvironmentError("commonmark must be version 0.6.0 or newer") 19 | 20 | 21 | def _get_func_info(docstring, def_lineno, code_lines, prefix): 22 | """ 23 | Extracts the function signature and description of a Python function 24 | 25 | :param docstring: 26 | A unicode string of the docstring for the function 27 | 28 | :param def_lineno: 29 | An integer line number that function was defined on 30 | 31 | :param code_lines: 32 | A list of unicode string lines from the source file the function was 33 | defined in 34 | 35 | :param prefix: 36 | A prefix to prepend to all output lines 37 | 38 | :return: 39 | A 2-element tuple: 40 | 41 | - [0] A unicode string of the function signature with a docstring of 42 | parameter info 43 | - [1] A markdown snippet of the function description 44 | """ 45 | 46 | def_index = def_lineno - 1 47 | definition = code_lines[def_index] 48 | definition = definition.rstrip() 49 | while not definition.endswith(':'): 50 | def_index += 1 51 | definition += '\n' + code_lines[def_index].rstrip() 52 | 53 | definition = textwrap.dedent(definition).rstrip(':') 54 | definition = definition.replace('\n', '\n' + prefix) 55 | 56 | description = '' 57 | found_colon = False 58 | 59 | params = '' 60 | 61 | for line in docstring.splitlines(): 62 | if line and line[0] == ':': 63 | found_colon = True 64 | if not found_colon: 65 | if description: 66 | description += '\n' 67 | description += line 68 | else: 69 | if params: 70 | params += '\n' 71 | params += line 72 | 73 | description = description.strip() 74 | description_md = '' 75 | if description: 76 | description_md = "%s%s" % (prefix, description.replace('\n', '\n' + prefix)) 77 | description_md = re.sub('\n>(\\s+)\n', '\n>\n', description_md) 78 | 79 | params = params.strip() 80 | if params: 81 | definition += (':\n%s """\n%s ' % (prefix, prefix)) 82 | definition += params.replace('\n', '\n%s ' % prefix) 83 | definition += ('\n%s """' % prefix) 84 | definition = re.sub('\n>(\\s+)\n', '\n>\n', definition) 85 | 86 | for search, replace in definition_replacements.items(): 87 | definition = definition.replace(search, replace) 88 | 89 | return (definition, description_md) 90 | 91 | 92 | def _find_sections(md_ast, sections, last, last_class, total_lines=None): 93 | """ 94 | Walks through a commonmark AST to find section headers that delineate 95 | content that should be updated by this script 96 | 97 | :param md_ast: 98 | The AST of the markdown document 99 | 100 | :param sections: 101 | A dict to store the start and end lines of a section. The key will be 102 | a two-element tuple of the section type ("class", "function", 103 | "method" or "attribute") and identifier. The values are a two-element 104 | tuple of the start and end line number in the markdown document of the 105 | section. 106 | 107 | :param last: 108 | A dict containing information about the last section header seen. 109 | Includes the keys "type_name", "identifier", "start_line". 110 | 111 | :param last_class: 112 | A unicode string of the name of the last class found - used when 113 | processing methods and attributes. 114 | 115 | :param total_lines: 116 | An integer of the total number of lines in the markdown document - 117 | used to work around a bug in the API of the Python port of commonmark 118 | """ 119 | 120 | def child_walker(node): 121 | for child, entering in node.walker(): 122 | if child == node: 123 | continue 124 | yield child, entering 125 | 126 | for child, entering in child_walker(md_ast): 127 | if child.t == 'heading': 128 | start_line = child.sourcepos[0][0] 129 | 130 | if child.level == 2: 131 | if last: 132 | sections[(last['type_name'], last['identifier'])] = (last['start_line'], start_line - 1) 133 | last.clear() 134 | 135 | if child.level in set([3, 5]): 136 | heading_elements = [] 137 | for heading_child, _ in child_walker(child): 138 | heading_elements.append(heading_child) 139 | if len(heading_elements) != 2: 140 | continue 141 | first = heading_elements[0] 142 | second = heading_elements[1] 143 | if first.t != 'code': 144 | continue 145 | if second.t != 'text': 146 | continue 147 | 148 | type_name = second.literal.strip() 149 | identifier = first.literal.strip().replace('()', '').lstrip('.') 150 | 151 | if last: 152 | sections[(last['type_name'], last['identifier'])] = (last['start_line'], start_line - 1) 153 | last.clear() 154 | 155 | if type_name == 'function': 156 | if child.level != 3: 157 | continue 158 | 159 | if type_name == 'class': 160 | if child.level != 3: 161 | continue 162 | last_class.append(identifier) 163 | 164 | if type_name in set(['method', 'attribute']): 165 | if child.level != 5: 166 | continue 167 | identifier = last_class[-1] + '.' + identifier 168 | 169 | last.update({ 170 | 'type_name': type_name, 171 | 'identifier': identifier, 172 | 'start_line': start_line, 173 | }) 174 | 175 | elif child.t == 'block_quote': 176 | find_sections(child, sections, last, last_class) 177 | 178 | if last: 179 | sections[(last['type_name'], last['identifier'])] = (last['start_line'], total_lines) 180 | 181 | 182 | find_sections = _find_sections 183 | 184 | 185 | def walk_ast(node, code_lines, sections, md_chunks): 186 | """ 187 | A callback used to walk the Python AST looking for classes, functions, 188 | methods and attributes. Generates chunks of markdown markup to replace 189 | the existing content. 190 | 191 | :param node: 192 | An _ast module node object 193 | 194 | :param code_lines: 195 | A list of unicode strings - the source lines of the Python file 196 | 197 | :param sections: 198 | A dict of markdown document sections that need to be updated. The key 199 | will be a two-element tuple of the section type ("class", "function", 200 | "method" or "attribute") and identifier. The values are a two-element 201 | tuple of the start and end line number in the markdown document of the 202 | section. 203 | 204 | :param md_chunks: 205 | A dict with keys from the sections param and the values being a unicode 206 | string containing a chunk of markdown markup. 207 | """ 208 | 209 | if isinstance(node, _ast.FunctionDef): 210 | key = ('function', node.name) 211 | if key not in sections: 212 | return 213 | 214 | docstring = ast.get_docstring(node) 215 | def_lineno = node.lineno + len(node.decorator_list) 216 | 217 | definition, description_md = _get_func_info(docstring, def_lineno, code_lines, '> ') 218 | 219 | md_chunk = textwrap.dedent(""" 220 | ### `%s()` function 221 | 222 | > ```python 223 | > %s 224 | > ``` 225 | > 226 | %s 227 | """).strip() % ( 228 | node.name, 229 | definition, 230 | description_md 231 | ) + "\n" 232 | 233 | md_chunks[key] = md_chunk.replace('>\n\n', '') 234 | 235 | elif isinstance(node, _ast.ClassDef): 236 | if ('class', node.name) not in sections: 237 | return 238 | 239 | for subnode in node.body: 240 | if isinstance(subnode, _ast.FunctionDef): 241 | node_id = node.name + '.' + subnode.name 242 | 243 | method_key = ('method', node_id) 244 | is_method = method_key in sections 245 | 246 | attribute_key = ('attribute', node_id) 247 | is_attribute = attribute_key in sections 248 | 249 | is_constructor = subnode.name == '__init__' 250 | 251 | if not is_constructor and not is_attribute and not is_method: 252 | continue 253 | 254 | docstring = ast.get_docstring(subnode) 255 | def_lineno = subnode.lineno 256 | if sys.version_info < (3, 8): 257 | def_lineno += len(subnode.decorator_list) 258 | 259 | if not docstring: 260 | continue 261 | 262 | if is_method or is_constructor: 263 | definition, description_md = _get_func_info(docstring, def_lineno, code_lines, '> > ') 264 | 265 | if is_constructor: 266 | key = ('class', node.name) 267 | 268 | class_docstring = ast.get_docstring(node) or '' 269 | class_description = textwrap.dedent(class_docstring).strip() 270 | if class_description: 271 | class_description_md = "> %s\n>" % (class_description.replace("\n", "\n> ")) 272 | else: 273 | class_description_md = '' 274 | 275 | md_chunk = textwrap.dedent(""" 276 | ### `%s()` class 277 | 278 | %s 279 | > ##### constructor 280 | > 281 | > > ```python 282 | > > %s 283 | > > ``` 284 | > > 285 | %s 286 | """).strip() % ( 287 | node.name, 288 | class_description_md, 289 | definition, 290 | description_md 291 | ) 292 | 293 | md_chunk = md_chunk.replace('\n\n\n', '\n\n') 294 | 295 | else: 296 | key = method_key 297 | 298 | md_chunk = textwrap.dedent(""" 299 | > 300 | > ##### `.%s()` method 301 | > 302 | > > ```python 303 | > > %s 304 | > > ``` 305 | > > 306 | %s 307 | """).strip() % ( 308 | subnode.name, 309 | definition, 310 | description_md 311 | ) 312 | 313 | if md_chunk[-5:] == '\n> >\n': 314 | md_chunk = md_chunk[0:-5] 315 | 316 | else: 317 | key = attribute_key 318 | 319 | description = textwrap.dedent(docstring).strip() 320 | description_md = "> > %s" % (description.replace("\n", "\n> > ")) 321 | 322 | md_chunk = textwrap.dedent(""" 323 | > 324 | > ##### `.%s` attribute 325 | > 326 | %s 327 | """).strip() % ( 328 | subnode.name, 329 | description_md 330 | ) 331 | 332 | md_chunks[key] = re.sub('[ \\t]+\n', '\n', md_chunk.rstrip()) 333 | 334 | elif isinstance(node, _ast.If): 335 | for subast in node.body: 336 | walk_ast(subast, code_lines, sections, md_chunks) 337 | for subast in node.orelse: 338 | walk_ast(subast, code_lines, sections, md_chunks) 339 | 340 | 341 | def run(): 342 | """ 343 | Looks through the docs/ dir and parses each markdown document, looking for 344 | sections to update from Python docstrings. Looks for section headers in 345 | the format: 346 | 347 | - ### `ClassName()` class 348 | - ##### `.method_name()` method 349 | - ##### `.attribute_name` attribute 350 | - ### `function_name()` function 351 | 352 | The markdown content following these section headers up until the next 353 | section header will be replaced by new markdown generated from the Python 354 | docstrings of the associated source files. 355 | 356 | By default maps docs/{name}.md to {modulename}/{name}.py. Allows for 357 | custom mapping via the md_source_map variable. 358 | """ 359 | 360 | print('Updating API docs...') 361 | 362 | md_files = [] 363 | for root, _, filenames in os.walk(os.path.join(package_root, 'docs')): 364 | for filename in filenames: 365 | if not filename.endswith('.md'): 366 | continue 367 | md_files.append(os.path.join(root, filename)) 368 | 369 | parser = commonmark.Parser() 370 | 371 | for md_file in md_files: 372 | md_file_relative = md_file[len(package_root) + 1:] 373 | if md_file_relative in md_source_map: 374 | py_files = md_source_map[md_file_relative] 375 | py_paths = [os.path.join(package_root, py_file) for py_file in py_files] 376 | else: 377 | py_files = [os.path.basename(md_file).replace('.md', '.py')] 378 | py_paths = [os.path.join(package_root, package_name, py_files[0])] 379 | 380 | if not os.path.exists(py_paths[0]): 381 | continue 382 | 383 | with open(md_file, 'rb') as f: 384 | markdown = f.read().decode('utf-8') 385 | 386 | original_markdown = markdown 387 | md_lines = list(markdown.splitlines()) 388 | md_ast = parser.parse(markdown) 389 | 390 | last_class = [] 391 | last = {} 392 | sections = OrderedDict() 393 | find_sections(md_ast, sections, last, last_class, markdown.count("\n") + 1) 394 | 395 | md_chunks = {} 396 | 397 | for index, py_file in enumerate(py_files): 398 | py_path = py_paths[index] 399 | 400 | with open(os.path.join(py_path), 'rb') as f: 401 | code = f.read().decode('utf-8') 402 | module_ast = ast.parse(code, filename=py_file) 403 | code_lines = list(code.splitlines()) 404 | 405 | for node in ast.iter_child_nodes(module_ast): 406 | walk_ast(node, code_lines, sections, md_chunks) 407 | 408 | added_lines = 0 409 | 410 | def _replace_md(key, sections, md_chunk, md_lines, added_lines): 411 | start, end = sections[key] 412 | start -= 1 413 | start += added_lines 414 | end += added_lines 415 | new_lines = md_chunk.split('\n') 416 | added_lines += len(new_lines) - (end - start) 417 | 418 | # Ensure a newline above each class header 419 | if start > 0 and md_lines[start][0:4] == '### ' and md_lines[start - 1][0:1] == '>': 420 | added_lines += 1 421 | new_lines.insert(0, '') 422 | 423 | md_lines[start:end] = new_lines 424 | return added_lines 425 | 426 | for key in sections: 427 | if key not in md_chunks: 428 | raise ValueError('No documentation found for %s' % key[1]) 429 | added_lines = _replace_md(key, sections, md_chunks[key], md_lines, added_lines) 430 | 431 | markdown = '\n'.join(md_lines).strip() + '\n' 432 | 433 | if original_markdown != markdown: 434 | with open(md_file, 'wb') as f: 435 | f.write(markdown.encode('utf-8')) 436 | 437 | 438 | if __name__ == '__main__': 439 | run() 440 | -------------------------------------------------------------------------------- /dev/build.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import os 5 | import tarfile 6 | import zipfile 7 | 8 | import setuptools.sandbox 9 | 10 | from . import package_root, package_name, has_tests_package 11 | from ._import import _import_from 12 | 13 | 14 | def _list_zip(filename): 15 | """ 16 | Prints all of the files in a .zip file 17 | """ 18 | 19 | zf = zipfile.ZipFile(filename, 'r') 20 | for name in zf.namelist(): 21 | print(' %s' % name) 22 | 23 | 24 | def _list_tgz(filename): 25 | """ 26 | Prints all of the files in a .tar.gz file 27 | """ 28 | 29 | tf = tarfile.open(filename, 'r:gz') 30 | for name in tf.getnames(): 31 | print(' %s' % name) 32 | 33 | 34 | def run(): 35 | """ 36 | Creates a sdist .tar.gz and a bdist_wheel --univeral .whl 37 | 38 | :return: 39 | A bool - if the packaging process was successful 40 | """ 41 | 42 | setup = os.path.join(package_root, 'setup.py') 43 | tests_root = os.path.join(package_root, 'tests') 44 | tests_setup = os.path.join(tests_root, 'setup.py') 45 | 46 | # Trying to call setuptools.sandbox.run_setup(setup, ['--version']) 47 | # resulted in a segfault, so we do this instead 48 | package_dir = os.path.join(package_root, package_name) 49 | version_mod = _import_from('%s.version' % package_name, package_dir, 'version') 50 | 51 | pkg_name_info = (package_name, version_mod.__version__) 52 | print('Building %s-%s' % pkg_name_info) 53 | 54 | sdist = '%s-%s.tar.gz' % pkg_name_info 55 | whl = '%s-%s-py2.py3-none-any.whl' % pkg_name_info 56 | setuptools.sandbox.run_setup(setup, ['-q', 'sdist']) 57 | print(' - created %s' % sdist) 58 | _list_tgz(os.path.join(package_root, 'dist', sdist)) 59 | setuptools.sandbox.run_setup(setup, ['-q', 'bdist_wheel', '--universal']) 60 | print(' - created %s' % whl) 61 | _list_zip(os.path.join(package_root, 'dist', whl)) 62 | setuptools.sandbox.run_setup(setup, ['-q', 'clean']) 63 | 64 | if has_tests_package: 65 | print('Building %s_tests-%s' % (package_name, version_mod.__version__)) 66 | 67 | tests_sdist = '%s_tests-%s.tar.gz' % pkg_name_info 68 | tests_whl = '%s_tests-%s-py2.py3-none-any.whl' % pkg_name_info 69 | setuptools.sandbox.run_setup(tests_setup, ['-q', 'sdist']) 70 | print(' - created %s' % tests_sdist) 71 | _list_tgz(os.path.join(tests_root, 'dist', tests_sdist)) 72 | setuptools.sandbox.run_setup(tests_setup, ['-q', 'bdist_wheel', '--universal']) 73 | print(' - created %s' % tests_whl) 74 | _list_zip(os.path.join(tests_root, 'dist', tests_whl)) 75 | setuptools.sandbox.run_setup(tests_setup, ['-q', 'clean']) 76 | 77 | dist_dir = os.path.join(package_root, 'dist') 78 | tests_dist_dir = os.path.join(tests_root, 'dist') 79 | os.rename( 80 | os.path.join(tests_dist_dir, tests_sdist), 81 | os.path.join(dist_dir, tests_sdist) 82 | ) 83 | os.rename( 84 | os.path.join(tests_dist_dir, tests_whl), 85 | os.path.join(dist_dir, tests_whl) 86 | ) 87 | os.rmdir(tests_dist_dir) 88 | 89 | return True 90 | -------------------------------------------------------------------------------- /dev/ci-cleanup.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import os 5 | import shutil 6 | 7 | from . import build_root, other_packages 8 | 9 | 10 | def run(): 11 | """ 12 | Cleans up CI dependencies - used for persistent GitHub Actions 13 | Runners since they don't clean themselves up. 14 | """ 15 | 16 | print("Removing ci dependencies") 17 | deps_dir = os.path.join(build_root, 'modularcrypto-deps') 18 | if os.path.exists(deps_dir): 19 | shutil.rmtree(deps_dir, ignore_errors=True) 20 | 21 | print("Removing modularcrypto packages") 22 | for other_package in other_packages: 23 | pkg_dir = os.path.join(build_root, other_package) 24 | if os.path.exists(pkg_dir): 25 | shutil.rmtree(pkg_dir, ignore_errors=True) 26 | print() 27 | 28 | return True 29 | -------------------------------------------------------------------------------- /dev/ci-driver.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import os 5 | import platform 6 | import sys 7 | import subprocess 8 | 9 | 10 | run_args = [ 11 | { 12 | 'name': 'cffi', 13 | 'kwarg': 'cffi', 14 | }, 15 | { 16 | 'name': 'openssl', 17 | 'kwarg': 'openssl', 18 | }, 19 | { 20 | 'name': 'winlegacy', 21 | 'kwarg': 'winlegacy', 22 | }, 23 | ] 24 | 25 | 26 | def _write_env(env, key, value): 27 | sys.stdout.write("%s: %s\n" % (key, value)) 28 | sys.stdout.flush() 29 | if sys.version_info < (3,): 30 | env[key.encode('utf-8')] = value.encode('utf-8') 31 | else: 32 | env[key] = value 33 | 34 | 35 | def run(**_): 36 | """ 37 | Runs CI, setting various env vars 38 | 39 | :return: 40 | A bool - if the CI ran successfully 41 | """ 42 | 43 | env = os.environ.copy() 44 | options = set(sys.argv[2:]) 45 | 46 | newline = False 47 | if 'cffi' not in options: 48 | _write_env(env, 'OSCRYPTO_USE_CTYPES', 'true') 49 | newline = True 50 | if 'openssl' in options and sys.platform == 'darwin': 51 | mac_version_info = tuple(map(int, platform.mac_ver()[0].split('.')[:2])) 52 | if mac_version_info < (10, 15): 53 | _write_env(env, 'OSCRYPTO_USE_OPENSSL', '/usr/lib/libcrypto.dylib,/usr/lib/libssl.dylib') 54 | else: 55 | _write_env(env, 'OSCRYPTO_USE_OPENSSL', '/usr/lib/libcrypto.35.dylib,/usr/lib/libssl.35.dylib') 56 | newline = True 57 | if 'openssl3' in options and sys.platform == 'darwin': 58 | _write_env( 59 | env, 60 | 'OSCRYPTO_USE_OPENSSL', 61 | '/usr/local/opt/openssl@3/lib/libcrypto.dylib,/usr/local/opt/openssl@3/lib/libssl.dylib' 62 | ) 63 | if 'winlegacy' in options: 64 | _write_env(env, 'OSCRYPTO_USE_WINLEGACY', 'true') 65 | newline = True 66 | 67 | if newline: 68 | sys.stdout.write("\n") 69 | 70 | proc = subprocess.Popen( 71 | [ 72 | sys.executable, 73 | 'run.py', 74 | 'ci', 75 | ], 76 | env=env 77 | ) 78 | proc.communicate() 79 | return proc.returncode == 0 80 | -------------------------------------------------------------------------------- /dev/ci.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import os 5 | import site 6 | import sys 7 | 8 | from . import build_root, requires_oscrypto 9 | from ._import import _preload 10 | 11 | 12 | deps_dir = os.path.join(build_root, 'modularcrypto-deps') 13 | if os.path.exists(deps_dir): 14 | site.addsitedir(deps_dir) 15 | # In case any of the deps are installed system-wide 16 | sys.path.insert(0, deps_dir) 17 | 18 | if sys.version_info[0:2] not in [(2, 6), (3, 2)]: 19 | from .lint import run as run_lint 20 | else: 21 | run_lint = None 22 | 23 | if sys.version_info[0:2] != (3, 2): 24 | from .coverage import run as run_coverage 25 | from .coverage import coverage 26 | run_tests = None 27 | 28 | else: 29 | from .tests import run as run_tests 30 | run_coverage = None 31 | 32 | 33 | def run(): 34 | """ 35 | Runs the linter and tests 36 | 37 | :return: 38 | A bool - if the linter and tests ran successfully 39 | """ 40 | 41 | _preload(requires_oscrypto, True) 42 | 43 | if run_lint: 44 | print('') 45 | lint_result = run_lint() 46 | else: 47 | lint_result = True 48 | 49 | if run_coverage: 50 | print('\nRunning tests (via coverage.py %s)' % coverage.__version__) 51 | sys.stdout.flush() 52 | tests_result = run_coverage(ci=True) 53 | else: 54 | print('\nRunning tests') 55 | sys.stdout.flush() 56 | tests_result = run_tests(ci=True) 57 | sys.stdout.flush() 58 | 59 | return lint_result and tests_result 60 | -------------------------------------------------------------------------------- /dev/codecov.json: -------------------------------------------------------------------------------- 1 | { 2 | "slug": "wbond/csrbuilder", 3 | "token": "1a5e2896-bb50-4bbb-a696-fa130b0ff2c4", 4 | "disabled": true 5 | } 6 | -------------------------------------------------------------------------------- /dev/coverage.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import cgi 5 | import codecs 6 | import coverage 7 | import json 8 | import os 9 | import unittest 10 | import re 11 | import sys 12 | import tempfile 13 | import time 14 | import platform as _plat 15 | import subprocess 16 | from fnmatch import fnmatch 17 | 18 | from . import package_name, package_root, other_packages 19 | from ._import import _import_from 20 | 21 | if sys.version_info < (3,): 22 | str_cls = unicode # noqa 23 | from urllib2 import URLError 24 | from urllib import urlencode 25 | from io import open 26 | else: 27 | str_cls = str 28 | from urllib.error import URLError 29 | from urllib.parse import urlencode 30 | 31 | if sys.version_info < (3, 7): 32 | Pattern = re._pattern_type 33 | else: 34 | Pattern = re.Pattern 35 | 36 | 37 | def run(ci=False): 38 | """ 39 | Runs the tests while measuring coverage 40 | 41 | :param ci: 42 | If coverage is being run in a CI environment - this triggers trying to 43 | run the tests for the rest of modularcrypto and uploading coverage data 44 | 45 | :return: 46 | A bool - if the tests ran successfully 47 | """ 48 | 49 | xml_report_path = os.path.join(package_root, 'coverage.xml') 50 | if os.path.exists(xml_report_path): 51 | os.unlink(xml_report_path) 52 | 53 | cov = coverage.Coverage(include='%s/*.py' % package_name) 54 | cov.start() 55 | 56 | from .tests import run as run_tests 57 | result = run_tests(ci=ci) 58 | print() 59 | 60 | if ci: 61 | suite = unittest.TestSuite() 62 | loader = unittest.TestLoader() 63 | for other_package in other_packages: 64 | for test_class in _load_package_tests(other_package): 65 | suite.addTest(loader.loadTestsFromTestCase(test_class)) 66 | 67 | if suite.countTestCases() > 0: 68 | print('Running tests from other modularcrypto packages') 69 | sys.stdout.flush() 70 | runner_result = unittest.TextTestRunner(stream=sys.stdout, verbosity=1).run(suite) 71 | result = runner_result.wasSuccessful() and result 72 | print() 73 | sys.stdout.flush() 74 | 75 | cov.stop() 76 | cov.save() 77 | 78 | cov.report(show_missing=False) 79 | print() 80 | sys.stdout.flush() 81 | if ci: 82 | cov.xml_report() 83 | 84 | if ci and result and os.path.exists(xml_report_path): 85 | _codecov_submit() 86 | print() 87 | 88 | return result 89 | 90 | 91 | def _load_package_tests(name): 92 | """ 93 | Load the test classes from another modularcrypto package 94 | 95 | :param name: 96 | A unicode string of the other package name 97 | 98 | :return: 99 | A list of unittest.TestCase classes of the tests for the package 100 | """ 101 | 102 | package_dir = os.path.join('..', name) 103 | if not os.path.exists(package_dir): 104 | return [] 105 | 106 | return _import_from('%s_tests' % name, package_dir, 'tests').test_classes() 107 | 108 | 109 | def _env_info(): 110 | """ 111 | :return: 112 | A two-element tuple of unicode strings. The first is the name of the 113 | environment, the second the root of the repo. The environment name 114 | will be one of: "ci-travis", "ci-circle", "ci-appveyor", 115 | "ci-github-actions", "local" 116 | """ 117 | 118 | if os.getenv('CI') == 'true' and os.getenv('TRAVIS') == 'true': 119 | return ('ci-travis', os.getenv('TRAVIS_BUILD_DIR')) 120 | 121 | if os.getenv('CI') == 'True' and os.getenv('APPVEYOR') == 'True': 122 | return ('ci-appveyor', os.getenv('APPVEYOR_BUILD_FOLDER')) 123 | 124 | if os.getenv('CI') == 'true' and os.getenv('CIRCLECI') == 'true': 125 | return ('ci-circle', os.getcwdu() if sys.version_info < (3,) else os.getcwd()) 126 | 127 | if os.getenv('GITHUB_ACTIONS') == 'true': 128 | return ('ci-github-actions', os.getenv('GITHUB_WORKSPACE')) 129 | 130 | return ('local', package_root) 131 | 132 | 133 | def _codecov_submit(): 134 | env_name, root = _env_info() 135 | 136 | try: 137 | with open(os.path.join(root, 'dev/codecov.json'), 'rb') as f: 138 | json_data = json.loads(f.read().decode('utf-8')) 139 | except (OSError, ValueError, UnicodeDecodeError, KeyError): 140 | print('error reading codecov.json') 141 | return 142 | 143 | if json_data.get('disabled'): 144 | return 145 | 146 | if env_name == 'ci-travis': 147 | # http://docs.travis-ci.com/user/environment-variables/#Default-Environment-Variables 148 | build_url = 'https://travis-ci.org/%s/jobs/%s' % (os.getenv('TRAVIS_REPO_SLUG'), os.getenv('TRAVIS_JOB_ID')) 149 | query = { 150 | 'service': 'travis', 151 | 'branch': os.getenv('TRAVIS_BRANCH'), 152 | 'build': os.getenv('TRAVIS_JOB_NUMBER'), 153 | 'pr': os.getenv('TRAVIS_PULL_REQUEST'), 154 | 'job': os.getenv('TRAVIS_JOB_ID'), 155 | 'tag': os.getenv('TRAVIS_TAG'), 156 | 'slug': os.getenv('TRAVIS_REPO_SLUG'), 157 | 'commit': os.getenv('TRAVIS_COMMIT'), 158 | 'build_url': build_url, 159 | } 160 | 161 | elif env_name == 'ci-appveyor': 162 | # http://www.appveyor.com/docs/environment-variables 163 | build_url = 'https://ci.appveyor.com/project/%s/build/%s' % ( 164 | os.getenv('APPVEYOR_REPO_NAME'), 165 | os.getenv('APPVEYOR_BUILD_VERSION') 166 | ) 167 | query = { 168 | 'service': "appveyor", 169 | 'branch': os.getenv('APPVEYOR_REPO_BRANCH'), 170 | 'build': os.getenv('APPVEYOR_JOB_ID'), 171 | 'pr': os.getenv('APPVEYOR_PULL_REQUEST_NUMBER'), 172 | 'job': '/'.join(( 173 | os.getenv('APPVEYOR_ACCOUNT_NAME'), 174 | os.getenv('APPVEYOR_PROJECT_SLUG'), 175 | os.getenv('APPVEYOR_BUILD_VERSION') 176 | )), 177 | 'tag': os.getenv('APPVEYOR_REPO_TAG_NAME'), 178 | 'slug': os.getenv('APPVEYOR_REPO_NAME'), 179 | 'commit': os.getenv('APPVEYOR_REPO_COMMIT'), 180 | 'build_url': build_url, 181 | } 182 | 183 | elif env_name == 'ci-circle': 184 | # https://circleci.com/docs/environment-variables 185 | query = { 186 | 'service': 'circleci', 187 | 'branch': os.getenv('CIRCLE_BRANCH'), 188 | 'build': os.getenv('CIRCLE_BUILD_NUM'), 189 | 'pr': os.getenv('CIRCLE_PR_NUMBER'), 190 | 'job': os.getenv('CIRCLE_BUILD_NUM') + "." + os.getenv('CIRCLE_NODE_INDEX'), 191 | 'tag': os.getenv('CIRCLE_TAG'), 192 | 'slug': os.getenv('CIRCLE_PROJECT_USERNAME') + "/" + os.getenv('CIRCLE_PROJECT_REPONAME'), 193 | 'commit': os.getenv('CIRCLE_SHA1'), 194 | 'build_url': os.getenv('CIRCLE_BUILD_URL'), 195 | } 196 | 197 | elif env_name == 'ci-github-actions': 198 | branch = '' 199 | tag = '' 200 | ref = os.getenv('GITHUB_REF', '') 201 | if ref.startswith('refs/tags/'): 202 | tag = ref[10:] 203 | elif ref.startswith('refs/heads/'): 204 | branch = ref[11:] 205 | 206 | impl = _plat.python_implementation() 207 | major, minor = _plat.python_version_tuple()[0:2] 208 | build_name = '%s %s %s.%s' % (_platform_name(), impl, major, minor) 209 | 210 | query = { 211 | 'service': 'custom', 212 | 'token': json_data['token'], 213 | 'branch': branch, 214 | 'tag': tag, 215 | 'slug': os.getenv('GITHUB_REPOSITORY'), 216 | 'commit': os.getenv('GITHUB_SHA'), 217 | 'build_url': 'https://github.com/wbond/oscrypto/commit/%s/checks' % os.getenv('GITHUB_SHA'), 218 | 'name': 'GitHub Actions %s on %s' % (build_name, os.getenv('RUNNER_OS')) 219 | } 220 | 221 | else: 222 | if not os.path.exists(os.path.join(root, '.git')): 223 | print('git repository not found, not submitting coverage data') 224 | return 225 | git_status = _git_command(['status', '--porcelain'], root) 226 | if git_status != '': 227 | print('git repository has uncommitted changes, not submitting coverage data') 228 | return 229 | 230 | branch = _git_command(['rev-parse', '--abbrev-ref', 'HEAD'], root) 231 | commit = _git_command(['rev-parse', '--verify', 'HEAD'], root) 232 | tag = _git_command(['name-rev', '--tags', '--name-only', commit], root) 233 | impl = _plat.python_implementation() 234 | major, minor = _plat.python_version_tuple()[0:2] 235 | build_name = '%s %s %s.%s' % (_platform_name(), impl, major, minor) 236 | query = { 237 | 'branch': branch, 238 | 'commit': commit, 239 | 'slug': json_data['slug'], 240 | 'token': json_data['token'], 241 | 'build': build_name, 242 | } 243 | if tag != 'undefined': 244 | query['tag'] = tag 245 | 246 | payload = 'PLATFORM=%s\n' % _platform_name() 247 | payload += 'PYTHON_VERSION=%s %s\n' % (_plat.python_version(), _plat.python_implementation()) 248 | if 'oscrypto' in sys.modules: 249 | payload += 'OSCRYPTO_BACKEND=%s\n' % sys.modules['oscrypto'].backend() 250 | payload += '<<<<<< ENV\n' 251 | 252 | for path in _list_files(root): 253 | payload += path + '\n' 254 | payload += '<<<<<< network\n' 255 | 256 | payload += '# path=coverage.xml\n' 257 | with open(os.path.join(root, 'coverage.xml'), 'r', encoding='utf-8') as f: 258 | payload += f.read() + '\n' 259 | payload += '<<<<<< EOF\n' 260 | 261 | url = 'https://codecov.io/upload/v4' 262 | headers = { 263 | 'Accept': 'text/plain' 264 | } 265 | filtered_query = {} 266 | for key in query: 267 | value = query[key] 268 | if value == '' or value is None: 269 | continue 270 | filtered_query[key] = value 271 | 272 | print('Submitting coverage info to codecov.io') 273 | info = _do_request( 274 | 'POST', 275 | url, 276 | headers, 277 | query_params=filtered_query 278 | ) 279 | 280 | encoding = info[1] or 'utf-8' 281 | text = info[2].decode(encoding).strip() 282 | parts = text.split() 283 | upload_url = parts[1] 284 | 285 | headers = { 286 | 'Content-Type': 'text/plain', 287 | 'x-amz-acl': 'public-read', 288 | 'x-amz-storage-class': 'REDUCED_REDUNDANCY' 289 | } 290 | 291 | print('Uploading coverage data to codecov.io S3 bucket') 292 | _do_request( 293 | 'PUT', 294 | upload_url, 295 | headers, 296 | data=payload.encode('utf-8') 297 | ) 298 | 299 | 300 | def _git_command(params, cwd): 301 | """ 302 | Executes a git command, returning the output 303 | 304 | :param params: 305 | A list of the parameters to pass to git 306 | 307 | :param cwd: 308 | The working directory to execute git in 309 | 310 | :return: 311 | A 2-element tuple of (stdout, stderr) 312 | """ 313 | 314 | proc = subprocess.Popen( 315 | ['git'] + params, 316 | stdout=subprocess.PIPE, 317 | stderr=subprocess.STDOUT, 318 | cwd=cwd 319 | ) 320 | stdout, stderr = proc.communicate() 321 | code = proc.wait() 322 | if code != 0: 323 | e = OSError('git exit code was non-zero') 324 | e.stdout = stdout 325 | raise e 326 | return stdout.decode('utf-8').strip() 327 | 328 | 329 | def _parse_env_var_file(data): 330 | """ 331 | Parses a basic VAR="value data" file contents into a dict 332 | 333 | :param data: 334 | A unicode string of the file data 335 | 336 | :return: 337 | A dict of parsed name/value data 338 | """ 339 | 340 | output = {} 341 | for line in data.splitlines(): 342 | line = line.strip() 343 | if not line or '=' not in line: 344 | continue 345 | parts = line.split('=') 346 | if len(parts) != 2: 347 | continue 348 | name = parts[0] 349 | value = parts[1] 350 | if len(value) > 1: 351 | if value[0] == '"' and value[-1] == '"': 352 | value = value[1:-1] 353 | output[name] = value 354 | return output 355 | 356 | 357 | def _platform_name(): 358 | """ 359 | Returns information about the current operating system and version 360 | 361 | :return: 362 | A unicode string containing the OS name and version 363 | """ 364 | 365 | if sys.platform == 'darwin': 366 | version = _plat.mac_ver()[0] 367 | _plat_ver_info = tuple(map(int, version.split('.'))) 368 | if _plat_ver_info < (10, 12): 369 | name = 'OS X' 370 | else: 371 | name = 'macOS' 372 | return '%s %s' % (name, version) 373 | 374 | elif sys.platform == 'win32': 375 | _win_ver = sys.getwindowsversion() 376 | _plat_ver_info = (_win_ver[0], _win_ver[1]) 377 | return 'Windows %s' % _plat.win32_ver()[0] 378 | 379 | elif sys.platform in ['linux', 'linux2']: 380 | if os.path.exists('/etc/os-release'): 381 | with open('/etc/os-release', 'r', encoding='utf-8') as f: 382 | pairs = _parse_env_var_file(f.read()) 383 | if 'NAME' in pairs and 'VERSION_ID' in pairs: 384 | return '%s %s' % (pairs['NAME'], pairs['VERSION_ID']) 385 | version = pairs['VERSION_ID'] 386 | elif 'PRETTY_NAME' in pairs: 387 | return pairs['PRETTY_NAME'] 388 | elif 'NAME' in pairs: 389 | return pairs['NAME'] 390 | else: 391 | raise ValueError('No suitable version info found in /etc/os-release') 392 | elif os.path.exists('/etc/lsb-release'): 393 | with open('/etc/lsb-release', 'r', encoding='utf-8') as f: 394 | pairs = _parse_env_var_file(f.read()) 395 | if 'DISTRIB_DESCRIPTION' in pairs: 396 | return pairs['DISTRIB_DESCRIPTION'] 397 | else: 398 | raise ValueError('No suitable version info found in /etc/lsb-release') 399 | else: 400 | return 'Linux' 401 | 402 | else: 403 | return '%s %s' % (_plat.system(), _plat.release()) 404 | 405 | 406 | def _list_files(root): 407 | """ 408 | Lists all of the files in a directory, taking into account any .gitignore 409 | file that is present 410 | 411 | :param root: 412 | A unicode filesystem path 413 | 414 | :return: 415 | A list of unicode strings, containing paths of all files not ignored 416 | by .gitignore with root, using relative paths 417 | """ 418 | 419 | dir_patterns, file_patterns = _gitignore(root) 420 | paths = [] 421 | prefix = os.path.abspath(root) + os.sep 422 | for base, dirs, files in os.walk(root): 423 | for d in dirs: 424 | for dir_pattern in dir_patterns: 425 | if fnmatch(d, dir_pattern): 426 | dirs.remove(d) 427 | break 428 | for f in files: 429 | skip = False 430 | for file_pattern in file_patterns: 431 | if fnmatch(f, file_pattern): 432 | skip = True 433 | break 434 | if skip: 435 | continue 436 | full_path = os.path.join(base, f) 437 | if full_path[:len(prefix)] == prefix: 438 | full_path = full_path[len(prefix):] 439 | paths.append(full_path) 440 | return sorted(paths) 441 | 442 | 443 | def _gitignore(root): 444 | """ 445 | Parses a .gitignore file and returns patterns to match dirs and files. 446 | Only basic gitignore patterns are supported. Pattern negation, ** wildcards 447 | and anchored patterns are not currently implemented. 448 | 449 | :param root: 450 | A unicode string of the path to the git repository 451 | 452 | :return: 453 | A 2-element tuple: 454 | - 0: a list of unicode strings to match against dirs 455 | - 1: a list of unicode strings to match against dirs and files 456 | """ 457 | 458 | gitignore_path = os.path.join(root, '.gitignore') 459 | 460 | dir_patterns = ['.git'] 461 | file_patterns = [] 462 | 463 | if not os.path.exists(gitignore_path): 464 | return (dir_patterns, file_patterns) 465 | 466 | with open(gitignore_path, 'r', encoding='utf-8') as f: 467 | for line in f.readlines(): 468 | line = line.strip() 469 | if not line: 470 | continue 471 | if line.startswith('#'): 472 | continue 473 | if '**' in line: 474 | raise NotImplementedError('gitignore ** wildcards are not implemented') 475 | if line.startswith('!'): 476 | raise NotImplementedError('gitignore pattern negation is not implemented') 477 | if line.startswith('/'): 478 | raise NotImplementedError('gitignore anchored patterns are not implemented') 479 | if line.startswith('\\#'): 480 | line = '#' + line[2:] 481 | if line.startswith('\\!'): 482 | line = '!' + line[2:] 483 | if line.endswith('/'): 484 | dir_patterns.append(line[:-1]) 485 | else: 486 | file_patterns.append(line) 487 | 488 | return (dir_patterns, file_patterns) 489 | 490 | 491 | def _do_request(method, url, headers, data=None, query_params=None, timeout=20): 492 | """ 493 | Performs an HTTP request 494 | 495 | :param method: 496 | A unicode string of 'POST' or 'PUT' 497 | 498 | :param url; 499 | A unicode string of the URL to request 500 | 501 | :param headers: 502 | A dict of unicode strings, where keys are header names and values are 503 | the header values. 504 | 505 | :param data: 506 | A dict of unicode strings (to be encoded as 507 | application/x-www-form-urlencoded), or a byte string of data. 508 | 509 | :param query_params: 510 | A dict of unicode keys and values to pass as query params 511 | 512 | :param timeout: 513 | An integer number of seconds to use as the timeout 514 | 515 | :return: 516 | A 3-element tuple: 517 | - 0: A unicode string of the response content-type 518 | - 1: A unicode string of the response encoding, or None 519 | - 2: A byte string of the response body 520 | """ 521 | 522 | if query_params: 523 | url += '?' + urlencode(query_params).replace('+', '%20') 524 | 525 | if isinstance(data, dict): 526 | data_bytes = {} 527 | for key in data: 528 | data_bytes[key.encode('utf-8')] = data[key].encode('utf-8') 529 | data = urlencode(data_bytes) 530 | headers['Content-Type'] = 'application/x-www-form-urlencoded' 531 | if isinstance(data, str_cls): 532 | raise TypeError('data must be a byte string') 533 | 534 | try: 535 | tempfd, tempf_path = tempfile.mkstemp('-coverage') 536 | os.write(tempfd, data or b'') 537 | os.close(tempfd) 538 | 539 | if sys.platform == 'win32': 540 | powershell_exe = os.path.join('system32\\WindowsPowerShell\\v1.0\\powershell.exe') 541 | code = "[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12;" 542 | code += "$wc = New-Object Net.WebClient;" 543 | for key in headers: 544 | code += "$wc.Headers.add('%s','%s');" % (key, headers[key]) 545 | code += "$out = $wc.UploadFile('%s', '%s', '%s');" % (url, method, tempf_path) 546 | code += "[System.Text.Encoding]::GetEncoding('ISO-8859-1').GetString($wc.ResponseHeaders.ToByteArray())" 547 | 548 | # To properly obtain bytes, we use BitConverter to get hex dash 549 | # encoding (e.g. AE-09-3F) and they decode in python 550 | code += " + [System.BitConverter]::ToString($out);" 551 | stdout, stderr = _execute( 552 | [powershell_exe, '-Command', code], 553 | os.getcwd(), 554 | re.compile(r'Unable to connect to|TLS|Internal Server Error'), 555 | 6 556 | ) 557 | if stdout[-2:] == b'\r\n' and b'\r\n\r\n' in stdout: 558 | # An extra trailing crlf is added at the end by powershell 559 | stdout = stdout[0:-2] 560 | parts = stdout.split(b'\r\n\r\n', 1) 561 | if len(parts) == 2: 562 | stdout = parts[0] + b'\r\n\r\n' + codecs.decode(parts[1].replace(b'-', b''), 'hex_codec') 563 | 564 | else: 565 | args = [ 566 | 'curl', 567 | '--http1.1', 568 | '--connect-timeout', '5', 569 | '--request', 570 | method, 571 | '--location', 572 | '--silent', 573 | '--show-error', 574 | '--include', 575 | # Prevent curl from asking for an HTTP "100 Continue" response 576 | '--header', 'Expect:' 577 | ] 578 | for key in headers: 579 | args.append('--header') 580 | args.append("%s: %s" % (key, headers[key])) 581 | args.append('--data-binary') 582 | args.append('@%s' % tempf_path) 583 | args.append(url) 584 | stdout, stderr = _execute( 585 | args, 586 | os.getcwd(), 587 | re.compile(r'Failed to connect to|TLS|SSLRead|outstanding|cleanly|timed out'), 588 | 6 589 | ) 590 | finally: 591 | if tempf_path and os.path.exists(tempf_path): 592 | os.remove(tempf_path) 593 | 594 | if len(stderr) > 0: 595 | raise URLError("Error %sing %s:\n%s" % (method, url, stderr)) 596 | 597 | parts = stdout.split(b'\r\n\r\n', 1) 598 | if len(parts) != 2: 599 | raise URLError("Error %sing %s, response data malformed:\n%s" % (method, url, stdout)) 600 | header_block, body = parts 601 | 602 | content_type_header = None 603 | content_len_header = None 604 | for hline in header_block.decode('iso-8859-1').splitlines(): 605 | hline_parts = hline.split(':', 1) 606 | if len(hline_parts) != 2: 607 | continue 608 | name, val = hline_parts 609 | name = name.strip().lower() 610 | val = val.strip() 611 | if name == 'content-type': 612 | content_type_header = val 613 | if name == 'content-length': 614 | content_len_header = val 615 | 616 | if content_type_header is None and content_len_header != '0': 617 | raise URLError("Error %sing %s, no content-type header:\n%s" % (method, url, stdout)) 618 | 619 | if content_type_header is None: 620 | content_type = 'text/plain' 621 | encoding = 'utf-8' 622 | else: 623 | content_type, params = cgi.parse_header(content_type_header) 624 | encoding = params.get('charset') 625 | 626 | return (content_type, encoding, body) 627 | 628 | 629 | def _execute(params, cwd, retry=None, retries=0, backoff=2): 630 | """ 631 | Executes a subprocess 632 | 633 | :param params: 634 | A list of the executable and arguments to pass to it 635 | 636 | :param cwd: 637 | The working directory to execute the command in 638 | 639 | :param retry: 640 | If this string is present in stderr, or regex pattern matches stderr, retry the operation 641 | 642 | :param retries: 643 | An integer number of times to retry 644 | 645 | :return: 646 | A 2-element tuple of (stdout, stderr) 647 | """ 648 | 649 | proc = subprocess.Popen( 650 | params, 651 | stdout=subprocess.PIPE, 652 | stderr=subprocess.PIPE, 653 | cwd=cwd 654 | ) 655 | stdout, stderr = proc.communicate() 656 | code = proc.wait() 657 | if code != 0: 658 | if retry and retries > 0: 659 | stderr_str = stderr.decode('utf-8') 660 | if isinstance(retry, Pattern): 661 | if retry.search(stderr_str) is not None: 662 | time.sleep(backoff) 663 | return _execute(params, cwd, retry, retries - 1, backoff * 2) 664 | elif retry in stderr_str: 665 | time.sleep(backoff) 666 | return _execute(params, cwd, retry, retries - 1, backoff * 2) 667 | e = OSError('subprocess exit code for "%s" was %d: %s' % (' '.join(params), code, stderr)) 668 | e.stdout = stdout 669 | e.stderr = stderr 670 | raise e 671 | return (stdout, stderr) 672 | 673 | 674 | if __name__ == '__main__': 675 | _codecov_submit() 676 | -------------------------------------------------------------------------------- /dev/deps.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import os 5 | import subprocess 6 | import sys 7 | import shutil 8 | import re 9 | import json 10 | import tarfile 11 | import zipfile 12 | 13 | from . import package_root, build_root, other_packages 14 | from ._pep425 import _pep425tags, _pep425_implementation 15 | 16 | if sys.version_info < (3,): 17 | str_cls = unicode # noqa 18 | else: 19 | str_cls = str 20 | 21 | 22 | def run(): 23 | """ 24 | Installs required development dependencies. Uses git to checkout other 25 | modularcrypto repos for more accurate coverage data. 26 | """ 27 | 28 | deps_dir = os.path.join(build_root, 'modularcrypto-deps') 29 | if os.path.exists(deps_dir): 30 | shutil.rmtree(deps_dir, ignore_errors=True) 31 | os.mkdir(deps_dir) 32 | 33 | try: 34 | print("Staging ci dependencies") 35 | _stage_requirements(deps_dir, os.path.join(package_root, 'requires', 'ci')) 36 | 37 | print("Checking out modularcrypto packages for coverage") 38 | for other_package in other_packages: 39 | pkg_url = 'https://github.com/wbond/%s.git' % other_package 40 | pkg_dir = os.path.join(build_root, other_package) 41 | if os.path.exists(pkg_dir): 42 | print("%s is already present" % other_package) 43 | continue 44 | print("Cloning %s" % pkg_url) 45 | _execute(['git', 'clone', pkg_url], build_root) 46 | print() 47 | 48 | except (Exception): 49 | if os.path.exists(deps_dir): 50 | shutil.rmtree(deps_dir, ignore_errors=True) 51 | raise 52 | 53 | return True 54 | 55 | 56 | def _download(url, dest): 57 | """ 58 | Downloads a URL to a directory 59 | 60 | :param url: 61 | The URL to download 62 | 63 | :param dest: 64 | The path to the directory to save the file in 65 | 66 | :return: 67 | The filesystem path to the saved file 68 | """ 69 | 70 | print('Downloading %s' % url) 71 | filename = os.path.basename(url) 72 | dest_path = os.path.join(dest, filename) 73 | 74 | if sys.platform == 'win32': 75 | powershell_exe = os.path.join('system32\\WindowsPowerShell\\v1.0\\powershell.exe') 76 | code = "[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12;" 77 | code += "(New-Object Net.WebClient).DownloadFile('%s', '%s');" % (url, dest_path) 78 | _execute([powershell_exe, '-Command', code], dest, 'Unable to connect to') 79 | 80 | else: 81 | _execute( 82 | ['curl', '-L', '--silent', '--show-error', '-O', url], 83 | dest, 84 | 'Failed to connect to' 85 | ) 86 | 87 | return dest_path 88 | 89 | 90 | def _tuple_from_ver(version_string): 91 | """ 92 | :param version_string: 93 | A unicode dotted version string 94 | 95 | :return: 96 | A tuple of integers 97 | """ 98 | 99 | match = re.search( 100 | r'(\d+(?:\.\d+)*)' 101 | r'([-._]?(?:alpha|a|beta|b|preview|pre|c|rc)\.?\d*)?' 102 | r'(-\d+|(?:[-._]?(?:rev|r|post)\.?\d*))?' 103 | r'([-._]?dev\.?\d*)?', 104 | version_string 105 | ) 106 | if not match: 107 | return tuple() 108 | 109 | nums = tuple(map(int, match.group(1).split('.'))) 110 | 111 | pre = match.group(2) 112 | if pre: 113 | pre = pre.replace('alpha', 'a') 114 | pre = pre.replace('beta', 'b') 115 | pre = pre.replace('preview', 'rc') 116 | pre = pre.replace('pre', 'rc') 117 | pre = re.sub(r'(?= (3,): 368 | env['PYTHONPATH'] = deps_dir 369 | else: 370 | env[b'PYTHONPATH'] = deps_dir.encode('utf-8') 371 | 372 | _execute( 373 | [ 374 | sys.executable, 375 | 'setup.py', 376 | 'install', 377 | '--root=%s' % root, 378 | '--install-lib=%s' % install_lib, 379 | '--no-compile' 380 | ], 381 | setup_dir, 382 | env=env 383 | ) 384 | 385 | finally: 386 | if ar: 387 | ar.close() 388 | if staging_dir: 389 | shutil.rmtree(staging_dir) 390 | 391 | 392 | def _sort_pep440_versions(releases, include_prerelease): 393 | """ 394 | :param releases: 395 | A list of unicode string PEP 440 version numbers 396 | 397 | :param include_prerelease: 398 | A boolean indicating if prerelease versions should be included 399 | 400 | :return: 401 | A sorted generator of 2-element tuples: 402 | 0: A unicode string containing a PEP 440 version number 403 | 1: A tuple of tuples containing integers - this is the output of 404 | _tuple_from_ver() for the PEP 440 version number and is intended 405 | for comparing versions 406 | """ 407 | 408 | parsed_versions = [] 409 | for v in releases: 410 | t = _tuple_from_ver(v) 411 | if not include_prerelease and t[1][0] < 0: 412 | continue 413 | parsed_versions.append((v, t)) 414 | 415 | return sorted(parsed_versions, key=lambda v: v[1]) 416 | 417 | 418 | def _is_valid_python_version(python_version, requires_python): 419 | """ 420 | Verifies the "python_version" and "requires_python" keys from a PyPi 421 | download record are applicable to the current version of Python 422 | 423 | :param python_version: 424 | The "python_version" value from a PyPi download JSON structure. This 425 | should be one of: "py2", "py3", "py2.py3" or "source". 426 | 427 | :param requires_python: 428 | The "requires_python" value from a PyPi download JSON structure. This 429 | will be None, or a comma-separated list of conditions that must be 430 | true. Ex: ">=3.5", "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 431 | """ 432 | 433 | if python_version == "py2" and sys.version_info >= (3,): 434 | return False 435 | if python_version == "py3" and sys.version_info < (3,): 436 | return False 437 | 438 | if requires_python is not None: 439 | 440 | def _ver_tuples(ver_str): 441 | ver_str = ver_str.strip() 442 | if ver_str.endswith('.*'): 443 | ver_str = ver_str[:-2] 444 | cond_tup = tuple(map(int, ver_str.split('.'))) 445 | return (sys.version_info[:len(cond_tup)], cond_tup) 446 | 447 | for part in map(str_cls.strip, requires_python.split(',')): 448 | if part.startswith('!='): 449 | sys_tup, cond_tup = _ver_tuples(part[2:]) 450 | if sys_tup == cond_tup: 451 | return False 452 | elif part.startswith('>='): 453 | sys_tup, cond_tup = _ver_tuples(part[2:]) 454 | if sys_tup < cond_tup: 455 | return False 456 | elif part.startswith('>'): 457 | sys_tup, cond_tup = _ver_tuples(part[1:]) 458 | if sys_tup <= cond_tup: 459 | return False 460 | elif part.startswith('<='): 461 | sys_tup, cond_tup = _ver_tuples(part[2:]) 462 | if sys_tup > cond_tup: 463 | return False 464 | elif part.startswith('<'): 465 | sys_tup, cond_tup = _ver_tuples(part[1:]) 466 | if sys_tup >= cond_tup: 467 | return False 468 | elif part.startswith('=='): 469 | sys_tup, cond_tup = _ver_tuples(part[2:]) 470 | if sys_tup != cond_tup: 471 | return False 472 | 473 | return True 474 | 475 | 476 | def _locate_suitable_download(downloads): 477 | """ 478 | :param downloads: 479 | A list of dicts containing a key "url", "python_version" and 480 | "requires_python" 481 | 482 | :return: 483 | A unicode string URL, or None if not a valid release for the current 484 | version of Python 485 | """ 486 | 487 | valid_tags = _pep425tags() 488 | 489 | exe_suffix = None 490 | if sys.platform == 'win32' and _pep425_implementation() == 'cp': 491 | win_arch = 'win32' if sys.maxsize == 2147483647 else 'win-amd64' 492 | version_info = sys.version_info 493 | exe_suffix = '.%s-py%d.%d.exe' % (win_arch, version_info[0], version_info[1]) 494 | 495 | wheels = {} 496 | whl = None 497 | tar_bz2 = None 498 | tar_gz = None 499 | exe = None 500 | for download in downloads: 501 | if not _is_valid_python_version(download.get('python_version'), download.get('requires_python')): 502 | continue 503 | 504 | if exe_suffix and download['url'].endswith(exe_suffix): 505 | exe = download['url'] 506 | if download['url'].endswith('.whl'): 507 | parts = os.path.basename(download['url']).split('-') 508 | tag_impl = parts[-3] 509 | tag_abi = parts[-2] 510 | tag_arch = parts[-1].split('.')[0] 511 | wheels[(tag_impl, tag_abi, tag_arch)] = download['url'] 512 | if download['url'].endswith('.tar.bz2'): 513 | tar_bz2 = download['url'] 514 | if download['url'].endswith('.tar.gz'): 515 | tar_gz = download['url'] 516 | 517 | # Find the most-specific wheel possible 518 | for tag in valid_tags: 519 | if tag in wheels: 520 | whl = wheels[tag] 521 | break 522 | 523 | if exe_suffix and exe: 524 | url = exe 525 | elif whl: 526 | url = whl 527 | elif tar_bz2: 528 | url = tar_bz2 529 | elif tar_gz: 530 | url = tar_gz 531 | else: 532 | return None 533 | 534 | return url 535 | 536 | 537 | def _stage_requirements(deps_dir, path): 538 | """ 539 | Installs requirements without using Python to download, since 540 | different services are limiting to TLS 1.2, and older version of 541 | Python do not support that 542 | 543 | :param deps_dir: 544 | A unicode path to a temporary directory to use for downloads 545 | 546 | :param path: 547 | A unicode filesystem path to a requirements file 548 | """ 549 | 550 | packages = _parse_requires(path) 551 | for p in packages: 552 | url = None 553 | pkg = p['pkg'] 554 | pkg_sub_dir = None 555 | if p['type'] == 'url': 556 | anchor = None 557 | if '#' in pkg: 558 | pkg, anchor = pkg.split('#', 1) 559 | if '&' in anchor: 560 | parts = anchor.split('&') 561 | else: 562 | parts = [anchor] 563 | for part in parts: 564 | param, value = part.split('=') 565 | if param == 'subdirectory': 566 | pkg_sub_dir = value 567 | 568 | if pkg.endswith('.zip') or pkg.endswith('.tar.gz') or pkg.endswith('.tar.bz2') or pkg.endswith('.whl'): 569 | url = pkg 570 | else: 571 | raise Exception('Unable to install package from URL that is not an archive') 572 | else: 573 | pypi_json_url = 'https://pypi.org/pypi/%s/json' % pkg 574 | json_dest = _download(pypi_json_url, deps_dir) 575 | with open(json_dest, 'rb') as f: 576 | pkg_info = json.loads(f.read().decode('utf-8')) 577 | if os.path.exists(json_dest): 578 | os.remove(json_dest) 579 | 580 | if p['type'] == '==': 581 | if p['ver'] not in pkg_info['releases']: 582 | raise Exception('Unable to find version %s of %s' % (p['ver'], pkg)) 583 | url = _locate_suitable_download(pkg_info['releases'][p['ver']]) 584 | if not url: 585 | raise Exception('Unable to find a compatible download of %s == %s' % (pkg, p['ver'])) 586 | else: 587 | p_ver_tup = _tuple_from_ver(p['ver']) 588 | for ver_str, ver_tup in reversed(_sort_pep440_versions(pkg_info['releases'], False)): 589 | if p['type'] == '>=' and ver_tup < p_ver_tup: 590 | break 591 | url = _locate_suitable_download(pkg_info['releases'][ver_str]) 592 | if url: 593 | break 594 | if not url: 595 | if p['type'] == '>=': 596 | raise Exception('Unable to find a compatible download of %s >= %s' % (pkg, p['ver'])) 597 | else: 598 | raise Exception('Unable to find a compatible download of %s' % pkg) 599 | 600 | local_path = _download(url, deps_dir) 601 | 602 | _extract_package(deps_dir, local_path, pkg_sub_dir) 603 | 604 | os.remove(local_path) 605 | 606 | 607 | def _parse_requires(path): 608 | """ 609 | Does basic parsing of pip requirements files, to allow for 610 | using something other than Python to do actual TLS requests 611 | 612 | :param path: 613 | A path to a requirements file 614 | 615 | :return: 616 | A list of dict objects containing the keys: 617 | - 'type' ('any', 'url', '==', '>=') 618 | - 'pkg' 619 | - 'ver' (if 'type' == '==' or 'type' == '>=') 620 | """ 621 | 622 | python_version = '.'.join(map(str_cls, sys.version_info[0:2])) 623 | sys_platform = sys.platform 624 | 625 | packages = [] 626 | 627 | with open(path, 'rb') as f: 628 | contents = f.read().decode('utf-8') 629 | 630 | for line in re.split(r'\r?\n', contents): 631 | line = line.strip() 632 | if not len(line): 633 | continue 634 | if re.match(r'^\s*#', line): 635 | continue 636 | if ';' in line: 637 | package, cond = line.split(';', 1) 638 | package = package.strip() 639 | cond = cond.strip() 640 | cond = cond.replace('sys_platform', repr(sys_platform)) 641 | cond = re.sub( 642 | r'[\'"]' 643 | r'(\d+(?:\.\d+)*)' 644 | r'([-._]?(?:alpha|a|beta|b|preview|pre|c|rc)\.?\d*)?' 645 | r'(-\d+|(?:[-._]?(?:rev|r|post)\.?\d*))?' 646 | r'([-._]?dev\.?\d*)?' 647 | r'[\'"]', 648 | r'_tuple_from_ver(\g<0>)', 649 | cond 650 | ) 651 | cond = cond.replace('python_version', '_tuple_from_ver(%r)' % python_version) 652 | if not eval(cond): 653 | continue 654 | else: 655 | package = line.strip() 656 | 657 | if re.match(r'^\s*-r\s*', package): 658 | sub_req_file = re.sub(r'^\s*-r\s*', '', package) 659 | sub_req_file = os.path.abspath(os.path.join(os.path.dirname(path), sub_req_file)) 660 | packages.extend(_parse_requires(sub_req_file)) 661 | continue 662 | 663 | if re.match(r'https?://', package): 664 | packages.append({'type': 'url', 'pkg': package}) 665 | continue 666 | 667 | if '>=' in package: 668 | parts = package.split('>=') 669 | package = parts[0].strip() 670 | ver = parts[1].strip() 671 | packages.append({'type': '>=', 'pkg': package, 'ver': ver}) 672 | continue 673 | 674 | if '==' in package: 675 | parts = package.split('==') 676 | package = parts[0].strip() 677 | ver = parts[1].strip() 678 | packages.append({'type': '==', 'pkg': package, 'ver': ver}) 679 | continue 680 | 681 | if re.search(r'[^ a-zA-Z0-9\-]', package): 682 | raise Exception('Unsupported requirements format version constraint: %s' % package) 683 | 684 | packages.append({'type': 'any', 'pkg': package}) 685 | 686 | return packages 687 | 688 | 689 | def _execute(params, cwd, retry=None, env=None): 690 | """ 691 | Executes a subprocess 692 | 693 | :param params: 694 | A list of the executable and arguments to pass to it 695 | 696 | :param cwd: 697 | The working directory to execute the command in 698 | 699 | :param retry: 700 | If this string is present in stderr, retry the operation 701 | 702 | :return: 703 | A 2-element tuple of (stdout, stderr) 704 | """ 705 | 706 | proc = subprocess.Popen( 707 | params, 708 | stdout=subprocess.PIPE, 709 | stderr=subprocess.PIPE, 710 | cwd=cwd, 711 | env=env 712 | ) 713 | stdout, stderr = proc.communicate() 714 | code = proc.wait() 715 | if code != 0: 716 | if retry and retry in stderr.decode('utf-8'): 717 | return _execute(params, cwd) 718 | e = OSError('subprocess exit code for "%s" was %d: %s' % (' '.join(params), code, stderr)) 719 | e.stdout = stdout 720 | e.stderr = stderr 721 | raise e 722 | return (stdout, stderr) 723 | -------------------------------------------------------------------------------- /dev/lint.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import os 5 | 6 | from . import package_name, package_root 7 | 8 | import flake8 9 | if not hasattr(flake8, '__version_info__') or flake8.__version_info__ < (3,): 10 | from flake8.engine import get_style_guide 11 | else: 12 | from flake8.api.legacy import get_style_guide 13 | 14 | 15 | def run(): 16 | """ 17 | Runs flake8 lint 18 | 19 | :return: 20 | A bool - if flake8 did not find any errors 21 | """ 22 | 23 | print('Running flake8 %s' % flake8.__version__) 24 | 25 | flake8_style = get_style_guide(config_file=os.path.join(package_root, 'tox.ini')) 26 | 27 | paths = [] 28 | for _dir in [package_name, 'dev', 'tests']: 29 | for root, _, filenames in os.walk(_dir): 30 | for filename in filenames: 31 | if not filename.endswith('.py'): 32 | continue 33 | paths.append(os.path.join(root, filename)) 34 | report = flake8_style.check_files(paths) 35 | success = report.total_errors == 0 36 | if success: 37 | print('OK') 38 | return success 39 | -------------------------------------------------------------------------------- /dev/pyenv-install.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import os 5 | import subprocess 6 | import sys 7 | 8 | 9 | run_args = [ 10 | { 11 | 'name': 'version', 12 | 'kwarg': 'version', 13 | }, 14 | ] 15 | 16 | 17 | def _write_env(env, key, value): 18 | sys.stdout.write("%s: %s\n" % (key, value)) 19 | sys.stdout.flush() 20 | if sys.version_info < (3,): 21 | env[key.encode('utf-8')] = value.encode('utf-8') 22 | else: 23 | env[key] = value 24 | 25 | 26 | def _shell_subproc(args): 27 | proc = subprocess.Popen( 28 | args, 29 | shell=True, 30 | stdout=subprocess.PIPE, 31 | stderr=subprocess.PIPE 32 | ) 33 | so, se = proc.communicate() 34 | stdout = so.decode('utf-8') 35 | stderr = se.decode('utf-8') 36 | return proc.returncode == 0, stdout, stderr 37 | 38 | 39 | def run(version=None): 40 | """ 41 | Installs a version of Python on Mac using pyenv 42 | 43 | :return: 44 | A bool - if Python was installed successfully 45 | """ 46 | 47 | if sys.platform == 'win32': 48 | raise ValueError('pyenv-install is not designed for Windows') 49 | 50 | if version not in set(['2.6', '2.7', '3.3']): 51 | raise ValueError('Invalid version: %r' % version) 52 | 53 | python_path = os.path.expanduser('~/.pyenv/versions/%s/bin' % version) 54 | if os.path.exists(os.path.join(python_path, 'python')): 55 | print(python_path) 56 | return True 57 | 58 | stdout = "" 59 | stderr = "" 60 | 61 | has_pyenv, _, _ = _shell_subproc('command -v pyenv') 62 | if not has_pyenv: 63 | success, stdout, stderr = _shell_subproc('brew install pyenv') 64 | if not success: 65 | print(stdout) 66 | print(stderr, file=sys.stderr) 67 | return False 68 | 69 | has_zlib, _, _ = _shell_subproc('brew list zlib') 70 | if not has_zlib: 71 | success, stdout, stderr = _shell_subproc('brew install zlib') 72 | if not success: 73 | print(stdout) 74 | print(stderr, file=sys.stderr) 75 | return False 76 | 77 | success, stdout, stderr = _shell_subproc('brew --prefix zlib') 78 | if not success: 79 | print(stdout) 80 | print(stderr, file=sys.stderr) 81 | return False 82 | zlib_prefix = stdout.strip() 83 | 84 | pyenv_script = './%s' % version 85 | try: 86 | with open(pyenv_script, 'wb') as f: 87 | if version == '2.6': 88 | contents = '#require_gcc\n' \ 89 | 'install_package "openssl-1.0.2k" "https://www.openssl.org/source/old/1.0.2/openssl-1.0.2k.tar.gz' \ 90 | '#6b3977c61f2aedf0f96367dcfb5c6e578cf37e7b8d913b4ecb6643c3cb88d8c0" mac_openssl\n' \ 91 | 'install_package "readline-8.0" "https://ftpmirror.gnu.org/readline/readline-8.0.tar.gz' \ 92 | '#e339f51971478d369f8a053a330a190781acb9864cf4c541060f12078948e461" mac_readline' \ 93 | ' --if has_broken_mac_readline\n' \ 94 | 'install_package "Python-2.6.9" "https://www.python.org/ftp/python/2.6.9/Python-2.6.9.tgz' \ 95 | '#7277b1285d8a82f374ef6ebaac85b003266f7939b3f2a24a3af52f9523ac94db" standard verify_py26' 96 | elif version == '2.7': 97 | contents = '#require_gcc\n' \ 98 | 'export PYTHON_BUILD_HOMEBREW_OPENSSL_FORMULA="openssl@1.1 openssl@1.0 openssl"\n' \ 99 | 'install_package "openssl-1.0.2q" "https://www.openssl.org/source/old/1.0.2/openssl-1.0.2q.tar.gz' \ 100 | '#5744cfcbcec2b1b48629f7354203bc1e5e9b5466998bbccc5b5fcde3b18eb684" mac_openssl ' \ 101 | '--if has_broken_mac_openssl\n' \ 102 | 'install_package "readline-8.0" "https://ftpmirror.gnu.org/readline/readline-8.0.tar.gz' \ 103 | '#e339f51971478d369f8a053a330a190781acb9864cf4c541060f12078948e461" mac_readline ' \ 104 | '--if has_broken_mac_readline\n' \ 105 | 'install_package "Python-2.7.18" "https://www.python.org/ftp/python/2.7.18/Python-2.7.18.tgz' \ 106 | '#da3080e3b488f648a3d7a4560ddee895284c3380b11d6de75edb986526b9a814" standard verify_py27 ' \ 107 | 'copy_python_gdb ensurepip\n' 108 | elif version == '3.3': 109 | contents = '#require_gcc\n' \ 110 | 'install_package "openssl-1.0.2k" "https://www.openssl.org/source/old/1.0.2/openssl-1.0.2k.tar.gz' \ 111 | '#6b3977c61f2aedf0f96367dcfb5c6e578cf37e7b8d913b4ecb6643c3cb88d8c0" mac_openssl\n' \ 112 | 'install_package "readline-8.0" "https://ftpmirror.gnu.org/readline/readline-8.0.tar.gz' \ 113 | '#e339f51971478d369f8a053a330a190781acb9864cf4c541060f12078948e461" mac_readline' \ 114 | ' --if has_broken_mac_readline\n' \ 115 | 'install_package "Python-3.3.7" "https://www.python.org/ftp/python/3.3.7/Python-3.3.7.tar.xz' \ 116 | '#85f60c327501c36bc18c33370c14d472801e6af2f901dafbba056f61685429fe" standard verify_py33' 117 | f.write(contents.encode('utf-8')) 118 | 119 | args = ['pyenv', 'install', pyenv_script] 120 | stdin = None 121 | stdin_contents = None 122 | env = os.environ.copy() 123 | 124 | _write_env(env, 'CFLAGS', '-I' + zlib_prefix + '/include') 125 | _write_env(env, 'LDFLAGS', '-L' + zlib_prefix + '/lib') 126 | 127 | if version == '2.6': 128 | _write_env(env, 'PYTHON_CONFIGURE_OPTS', '--enable-ipv6') 129 | stdin = subprocess.PIPE 130 | stdin_contents = '--- configure 2021-08-05 20:17:26.000000000 -0400\n' \ 131 | '+++ configure 2021-08-05 20:21:30.000000000 -0400\n' \ 132 | '@@ -10300,17 +10300,8 @@\n' \ 133 | ' rm -f core conftest.err conftest.$ac_objext \\\n' \ 134 | ' conftest$ac_exeext conftest.$ac_ext\n' \ 135 | ' \n' \ 136 | '-if test "$buggygetaddrinfo" = "yes"; then\n' \ 137 | '-\tif test "$ipv6" = "yes"; then\n' \ 138 | '-\t\techo \'Fatal: You must get working getaddrinfo() function.\'\n' \ 139 | '-\t\techo \' or you can specify "--disable-ipv6"\'.\n' \ 140 | '-\t\texit 1\n' \ 141 | '-\tfi\n' \ 142 | '-else\n' \ 143 | '-\n' \ 144 | ' $as_echo "#define HAVE_GETADDRINFO 1" >>confdefs.h\n' \ 145 | ' \n' \ 146 | '-fi\n' \ 147 | ' for ac_func in getnameinfo\n' \ 148 | ' do :\n' \ 149 | ' ac_fn_c_check_func "$LINENO" "getnameinfo" "ac_cv_func_getnameinfo"' 150 | stdin_contents = stdin_contents.encode('ascii') 151 | args.append('--patch') 152 | elif version == '3.3': 153 | stdin = subprocess.PIPE 154 | stdin_contents = '--- configure\n' \ 155 | '+++ configure\n' \ 156 | '@@ -3391,7 +3391,7 @@ $as_echo "#define _BSD_SOURCE 1" >>confdefs.h\n' \ 157 | ' # has no effect, don\'t bother defining them\n' \ 158 | ' Darwin/[6789].*)\n' \ 159 | ' define_xopen_source=no;;\n' \ 160 | '- Darwin/1[0-9].*)\n' \ 161 | '+ Darwin/[12][0-9].*)\n' \ 162 | ' define_xopen_source=no;;\n' \ 163 | ' # On AIX 4 and 5.1, mbstate_t is defined only when _XOPEN_SOURCE == 500 but\n' \ 164 | ' # used in wcsnrtombs() and mbsnrtowcs() even if _XOPEN_SOURCE is not defined\n' \ 165 | '--- configure.ac\n' \ 166 | '+++ configure.ac\n' \ 167 | '@@ 480,7 +480,7 @@ case $ac_sys_system/$ac_sys_release in\n' \ 168 | ' # has no effect, don\'t bother defining them\n' \ 169 | ' Darwin/@<:@6789@:>@.*)\n' \ 170 | ' define_xopen_source=no;;\n' \ 171 | '- Darwin/1@<:@0-9@:>@.*)\n' \ 172 | '+ Darwin/@<:@[12]@:>@@<:@0-9@:>@.*)\n' \ 173 | ' define_xopen_source=no;;\n' \ 174 | ' # On AIX 4 and 5.1, mbstate_t is defined only when _XOPEN_SOURCE == 500 but\n' \ 175 | ' # used in wcsnrtombs() and mbsnrtowcs() even if _XOPEN_SOURCE is not defined\n' 176 | stdin_contents = stdin_contents.encode('ascii') 177 | args.append('--patch') 178 | 179 | proc = subprocess.Popen( 180 | args, 181 | stdout=subprocess.PIPE, 182 | stderr=subprocess.PIPE, 183 | stdin=stdin, 184 | env=env 185 | ) 186 | so, se = proc.communicate(stdin_contents) 187 | stdout += so.decode('utf-8') 188 | stderr += se.decode('utf-8') 189 | 190 | if proc.returncode != 0: 191 | print(stdout) 192 | print(stderr, file=sys.stderr) 193 | return False 194 | 195 | finally: 196 | if os.path.exists(pyenv_script): 197 | os.unlink(pyenv_script) 198 | 199 | print(python_path) 200 | return True 201 | -------------------------------------------------------------------------------- /dev/python-install.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import os 5 | import shutil 6 | import subprocess 7 | import sys 8 | from urllib.parse import urlparse 9 | from urllib.request import urlopen 10 | 11 | 12 | run_args = [ 13 | { 14 | 'name': 'version', 15 | 'kwarg': 'version', 16 | }, 17 | { 18 | 'name': 'arch', 19 | 'kwarg': 'arch', 20 | }, 21 | ] 22 | 23 | 24 | def run(version=None, arch=None): 25 | """ 26 | Installs a version of Python on Windows 27 | 28 | :return: 29 | A bool - if Python was installed successfully 30 | """ 31 | 32 | if sys.platform != 'win32': 33 | raise ValueError('python-install is only designed for Windows') 34 | 35 | if version not in set(['2.6', '2.7', '3.3']): 36 | raise ValueError('Invalid version: %r' % version) 37 | 38 | if arch not in set(['x86', 'x64']): 39 | raise ValueError('Invalid arch: %r' % arch) 40 | 41 | if version == '2.6': 42 | if arch == 'x64': 43 | url = 'https://www.python.org/ftp/python/2.6.6/python-2.6.6.amd64.msi' 44 | else: 45 | url = 'https://www.python.org/ftp/python/2.6.6/python-2.6.6.msi' 46 | elif version == '2.7': 47 | if arch == 'x64': 48 | url = 'https://www.python.org/ftp/python/2.7.18/python-2.7.18.amd64.msi' 49 | else: 50 | url = 'https://www.python.org/ftp/python/2.7.18/python-2.7.18.msi' 51 | else: 52 | if arch == 'x64': 53 | url = 'https://www.python.org/ftp/python/3.3.5/python-3.3.5.amd64.msi' 54 | else: 55 | url = 'https://www.python.org/ftp/python/3.3.5/python-3.3.5.msi' 56 | 57 | home = os.environ.get('USERPROFILE') 58 | msi_filename = os.path.basename(urlparse(url).path) 59 | msi_path = os.path.join(home, msi_filename) 60 | install_path = os.path.join(os.environ.get('LOCALAPPDATA'), 'Python%s-%s' % (version, arch)) 61 | 62 | if os.path.exists(os.path.join(install_path, 'python.exe')): 63 | print(install_path) 64 | return True 65 | 66 | try: 67 | with urlopen(url) as r, open(msi_path, 'wb') as f: 68 | shutil.copyfileobj(r, f) 69 | 70 | proc = subprocess.Popen( 71 | 'msiexec /passive /a %s TARGETDIR=%s' % (msi_filename, install_path), 72 | shell=True, 73 | cwd=home 74 | ) 75 | proc.communicate() 76 | 77 | finally: 78 | if os.path.exists(msi_path): 79 | os.unlink(msi_path) 80 | 81 | print(install_path) 82 | return True 83 | -------------------------------------------------------------------------------- /dev/release.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import subprocess 5 | import sys 6 | 7 | import twine.cli 8 | 9 | from . import package_name, package_root, has_tests_package 10 | from .build import run as build 11 | 12 | 13 | def run(): 14 | """ 15 | Creates a sdist .tar.gz and a bdist_wheel --univeral .whl and uploads 16 | them to pypi 17 | 18 | :return: 19 | A bool - if the packaging and upload process was successful 20 | """ 21 | 22 | git_wc_proc = subprocess.Popen( 23 | ['git', 'status', '--porcelain', '-uno'], 24 | stdout=subprocess.PIPE, 25 | stderr=subprocess.STDOUT, 26 | cwd=package_root 27 | ) 28 | git_wc_status, _ = git_wc_proc.communicate() 29 | 30 | if len(git_wc_status) > 0: 31 | print(git_wc_status.decode('utf-8').rstrip(), file=sys.stderr) 32 | print('Unable to perform release since working copy is not clean', file=sys.stderr) 33 | return False 34 | 35 | git_tag_proc = subprocess.Popen( 36 | ['git', 'tag', '-l', '--contains', 'HEAD'], 37 | stdout=subprocess.PIPE, 38 | stderr=subprocess.PIPE, 39 | cwd=package_root 40 | ) 41 | tag, tag_error = git_tag_proc.communicate() 42 | 43 | if len(tag_error) > 0: 44 | print(tag_error.decode('utf-8').rstrip(), file=sys.stderr) 45 | print('Error looking for current git tag', file=sys.stderr) 46 | return False 47 | 48 | if len(tag) == 0: 49 | print('No git tag found on HEAD', file=sys.stderr) 50 | return False 51 | 52 | tag = tag.decode('ascii').strip() 53 | 54 | build() 55 | 56 | twine.cli.dispatch(['upload', 'dist/%s-%s*' % (package_name, tag)]) 57 | if has_tests_package: 58 | twine.cli.dispatch(['upload', 'dist/%s_tests-%s*' % (package_name, tag)]) 59 | 60 | return True 61 | -------------------------------------------------------------------------------- /dev/tests.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import unittest 5 | import re 6 | import sys 7 | import warnings 8 | 9 | from . import requires_oscrypto 10 | from ._import import _preload 11 | 12 | from tests import test_classes 13 | 14 | if sys.version_info < (3,): 15 | range = xrange # noqa 16 | from cStringIO import StringIO 17 | else: 18 | from io import StringIO 19 | 20 | 21 | run_args = [ 22 | { 23 | 'name': 'regex', 24 | 'kwarg': 'matcher', 25 | }, 26 | { 27 | 'name': 'repeat_count', 28 | 'kwarg': 'repeat', 29 | 'cast': 'int', 30 | }, 31 | ] 32 | 33 | 34 | def run(matcher=None, repeat=1, ci=False): 35 | """ 36 | Runs the tests 37 | 38 | :param matcher: 39 | A unicode string containing a regular expression to use to filter test 40 | names by. A value of None will cause no filtering. 41 | 42 | :param repeat: 43 | An integer - the number of times to run the tests 44 | 45 | :param ci: 46 | A bool, indicating if the tests are being run as part of CI 47 | 48 | :return: 49 | A bool - if the tests succeeded 50 | """ 51 | 52 | _preload(requires_oscrypto, not ci) 53 | 54 | warnings.filterwarnings("error") 55 | 56 | loader = unittest.TestLoader() 57 | # We have to manually track the list of applicable tests because for 58 | # some reason with Python 3.4 on Windows, the tests in a suite are replaced 59 | # with None after being executed. This breaks the repeat functionality. 60 | test_list = [] 61 | for test_class in test_classes(): 62 | if matcher: 63 | names = loader.getTestCaseNames(test_class) 64 | for name in names: 65 | if re.search(matcher, name): 66 | test_list.append(test_class(name)) 67 | else: 68 | test_list.append(loader.loadTestsFromTestCase(test_class)) 69 | 70 | stream = sys.stdout 71 | verbosity = 1 72 | if matcher and repeat == 1: 73 | verbosity = 2 74 | elif repeat > 1: 75 | stream = StringIO() 76 | 77 | for _ in range(0, repeat): 78 | suite = unittest.TestSuite() 79 | for test in test_list: 80 | suite.addTest(test) 81 | result = unittest.TextTestRunner(stream=stream, verbosity=verbosity).run(suite) 82 | 83 | if len(result.errors) > 0 or len(result.failures) > 0: 84 | if repeat > 1: 85 | print(stream.getvalue()) 86 | return False 87 | 88 | if repeat > 1: 89 | stream.truncate(0) 90 | 91 | return True 92 | -------------------------------------------------------------------------------- /dev/version.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import codecs 5 | import os 6 | import re 7 | 8 | from . import package_root, package_name, has_tests_package 9 | 10 | 11 | run_args = [ 12 | { 13 | 'name': 'pep440_version', 14 | 'required': True 15 | }, 16 | ] 17 | 18 | 19 | def run(new_version): 20 | """ 21 | Updates the package version in the various locations 22 | 23 | :param new_version: 24 | A unicode string of the new library version as a PEP 440 version 25 | 26 | :return: 27 | A bool - if the version number was successfully bumped 28 | """ 29 | 30 | # We use a restricted form of PEP 440 versions 31 | version_match = re.match( 32 | r'(\d+)\.(\d+)\.(\d)+(?:\.((?:dev|a|b|rc)\d+))?$', 33 | new_version 34 | ) 35 | if not version_match: 36 | raise ValueError('Invalid PEP 440 version: %s' % new_version) 37 | 38 | new_version_info = ( 39 | int(version_match.group(1)), 40 | int(version_match.group(2)), 41 | int(version_match.group(3)), 42 | ) 43 | if version_match.group(4): 44 | new_version_info += (version_match.group(4),) 45 | 46 | version_path = os.path.join(package_root, package_name, 'version.py') 47 | setup_path = os.path.join(package_root, 'setup.py') 48 | setup_tests_path = os.path.join(package_root, 'tests', 'setup.py') 49 | tests_path = os.path.join(package_root, 'tests', '__init__.py') 50 | 51 | file_paths = [version_path, setup_path] 52 | if has_tests_package: 53 | file_paths.extend([setup_tests_path, tests_path]) 54 | 55 | for file_path in file_paths: 56 | orig_source = '' 57 | with codecs.open(file_path, 'r', encoding='utf-8') as f: 58 | orig_source = f.read() 59 | 60 | found = 0 61 | new_source = '' 62 | for line in orig_source.splitlines(True): 63 | if line.startswith('__version__ = '): 64 | found += 1 65 | new_source += '__version__ = %r\n' % new_version 66 | elif line.startswith('__version_info__ = '): 67 | found += 1 68 | new_source += '__version_info__ = %r\n' % (new_version_info,) 69 | elif line.startswith('PACKAGE_VERSION = '): 70 | found += 1 71 | new_source += 'PACKAGE_VERSION = %r\n' % new_version 72 | else: 73 | new_source += line 74 | 75 | if found == 0: 76 | raise ValueError('Did not find any versions in %s' % file_path) 77 | 78 | s = 's' if found > 1 else '' 79 | rel_path = file_path[len(package_root) + 1:] 80 | was_were = 'was' if found == 1 else 'were' 81 | if new_source != orig_source: 82 | print('Updated %d version%s in %s' % (found, s, rel_path)) 83 | with codecs.open(file_path, 'w', encoding='utf-8') as f: 84 | f.write(new_source) 85 | else: 86 | print('%d version%s in %s %s up-to-date' % (found, s, rel_path, was_were)) 87 | 88 | return True 89 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # csrbuilder API Documentation 2 | 3 | ### `pem_armor_csr()` function 4 | 5 | > ```python 6 | > def pem_armor_csr(certification_request): 7 | > """ 8 | > :param certification_request: 9 | > An asn1crypto.csr.CertificationRequest object of the CSR to armor. 10 | > Typically this is obtained from CSRBuilder.build(). 11 | > 12 | > :return: 13 | > A byte string of the PEM-encoded CSR 14 | > """ 15 | > ``` 16 | > 17 | > Encodes a CSR into PEM format 18 | 19 | ### `CSRBuilder()` class 20 | 21 | > ##### constructor 22 | > 23 | > > ```python 24 | > > def __init__(self, subject, subject_public_key): 25 | > > """ 26 | > > :param subject: 27 | > > An asn1crypto.x509.Name object, or a dict - see the docstring 28 | > > for .subject for a list of valid options 29 | > > 30 | > > :param subject_public_key: 31 | > > An asn1crypto.keys.PublicKeyInfo object containing the public key 32 | > > the certificate is being requested for 33 | > > """ 34 | > > ``` 35 | > > 36 | > > Unless changed, CSRs will use SHA-256 for the signature 37 | > 38 | > ##### `.subject` attribute 39 | > 40 | > > An asn1crypto.x509.Name object, or a dict with at least the 41 | > > following keys: 42 | > > 43 | > > - country_name 44 | > > - state_or_province_name 45 | > > - locality_name 46 | > > - organization_name 47 | > > - common_name 48 | > > 49 | > > Less common keys include: 50 | > > 51 | > > - organizational_unit_name 52 | > > - email_address 53 | > > - street_address 54 | > > - postal_code 55 | > > - business_category 56 | > > - incorporation_locality 57 | > > - incorporation_state_or_province 58 | > > - incorporation_country 59 | > > 60 | > > Uncommon keys include: 61 | > > 62 | > > - surname 63 | > > - title 64 | > > - serial_number 65 | > > - name 66 | > > - given_name 67 | > > - initials 68 | > > - generation_qualifier 69 | > > - dn_qualifier 70 | > > - pseudonym 71 | > > - domain_component 72 | > > 73 | > > All values should be unicode strings 74 | > 75 | > ##### `.subject_public_key` attribute 76 | > 77 | > > An asn1crypto.keys.PublicKeyInfo or oscrypto.asymmetric.PublicKey 78 | > > object of the subject's public key. 79 | > 80 | > ##### `.hash_algo` attribute 81 | > 82 | > > A unicode string of the hash algorithm to use when signing the 83 | > > request - "sha1" (not recommended), "sha256" or "sha512" 84 | > 85 | > ##### `.ca` attribute 86 | > 87 | > > None or a bool - if the request is for a CA cert. None indicates no 88 | > > basic constraints extension request. 89 | > 90 | > ##### `.subject_alt_domains` attribute 91 | > 92 | > > A list of unicode strings of all domains in the subject alt name 93 | > > extension request. Empty list indicates no subject alt name extension 94 | > > request. 95 | > 96 | > ##### `.subject_alt_ips` attribute 97 | > 98 | > > A list of unicode strings of all IPs in the subject alt name extension 99 | > > request. Empty list indicates no subject alt name extension request. 100 | > 101 | > ##### `.key_usage` attribute 102 | > 103 | > > A set of unicode strings representing the allowed usage of the key. 104 | > > Empty set indicates no key usage extension request. 105 | > 106 | > ##### `.extended_key_usage` attribute 107 | > 108 | > > A set of unicode strings representing the allowed usage of the key from 109 | > > the extended key usage extension. Empty set indicates no extended key 110 | > > usage extension request. 111 | > 112 | > ##### `.set_extension()` method 113 | > 114 | > > ```python 115 | > > def set_extension(self, name, value): 116 | > > """ 117 | > > :param name: 118 | > > A unicode string of an extension id name from 119 | > > asn1crypto.x509.ExtensionId 120 | > > 121 | > > :param value: 122 | > > A value object per the specs defined by asn1crypto.x509.Extension 123 | > > """ 124 | > > ``` 125 | > > 126 | > > Sets the value for an extension using a fully constructed Asn1Value 127 | > > object from asn1crypto. Normally this should not be needed, and the 128 | > > convenience attributes should be sufficient. 129 | > > 130 | > > See the definition of asn1crypto.x509.Extension to determine the 131 | > > appropriate object type for a given extension. Extensions are marked 132 | > > as critical when RFC5280 or RFC6960 indicate so. If an extension is 133 | > > validly marked as critical or not (such as certificate policies and 134 | > > extended key usage), this class will mark it as non-critical. 135 | > 136 | > ##### `.build()` method 137 | > 138 | > > ```python 139 | > > def build(self, signing_private_key): 140 | > > """ 141 | > > :param signing_private_key: 142 | > > An asn1crypto.keys.PrivateKeyInfo or oscrypto.asymmetric.PrivateKey 143 | > > object for the private key to sign the request with. This should be 144 | > > the private key that matches the public key. 145 | > > 146 | > > :return: 147 | > > An asn1crypto.csr.CertificationRequest object of the request 148 | > > """ 149 | > > ``` 150 | > > 151 | > > Validates the certificate information, constructs an X.509 certificate 152 | > > and then signs it 153 | -------------------------------------------------------------------------------- /docs/readme.md: -------------------------------------------------------------------------------- 1 | # csrbuilder Documentation 2 | 3 | *csrbuilder* is a Python library for constructing CSRs - certificate signing 4 | requests. It provides a high-level interface with knowledge of RFC 2986 to 5 | produce, valid, correct requests without terrible APIs or hunting through RFCs. 6 | 7 | Since its only dependencies are the 8 | [*asn1crypto*](https://github.com/wbond/asn1crypto#readme) and 9 | [*oscrypto*](https://github.com/wbond/oscrypto#readme) libraries, it is 10 | easy to install and use on Windows, OS X, Linux and the BSDs. 11 | 12 | The documentation consists of the following topics: 13 | 14 | - [Basic Usage](#basic-usage) 15 | - [API Documentation](api.md) 16 | 17 | ## Basic Usage 18 | 19 | A simple, self-signed certificate can be created by generating a public/private 20 | key pair using *oscrypto* and then passing a dictionary of name information to 21 | the `CSRBuilder()` constructor: 22 | 23 | ```python 24 | from oscrypto import asymmetric 25 | from csrbuilder import CSRBuilder, pem_armor_csr 26 | 27 | 28 | public_key, private_key = asymmetric.generate_pair('rsa', bit_size=2048) 29 | 30 | with open('/path/to/my/env/will_bond.key', 'wb') as f: 31 | f.write(asymmetric.dump_private_key(private_key, 'password')) 32 | 33 | builder = CSRBuilder( 34 | { 35 | 'country_name': 'US', 36 | 'state_or_province_name': 'Massachusetts', 37 | 'locality_name': 'Newbury', 38 | 'organization_name': 'Codex Non Sufficit LC', 39 | 'common_name': 'Will Bond', 40 | }, 41 | public_key 42 | ) 43 | # Add subjectAltName domains 44 | builder.subject_alt_domains = ['codexns.io', 'codexns.com'] 45 | request = builder.build(private_key) 46 | 47 | with open('/path/to/my/env/will_bond.csr', 'wb') as f: 48 | f.write(pem_armor_csr(request)) 49 | ``` 50 | 51 | All name components must be unicode strings. Common name keys include: 52 | 53 | - `country_name` 54 | - `state_or_province_name` 55 | - `locality_name` 56 | - `organization_name` 57 | - `common_name` 58 | 59 | Less common keys include: 60 | 61 | - `organizational_unit_name` 62 | - `email_address` 63 | - `street_address` 64 | - `postal_code` 65 | - `business_category` 66 | - `incorporation_locality` 67 | - `incorporation_state_or_province` 68 | - `incorporation_country` 69 | 70 | See [`CSRBuilder.subject`](api.md#subject-attribute) for a full 71 | list of supported name keys. 72 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # csrbuilder 2 | 3 | A Python library for creating and signing X.509 certificate signing requests 4 | (CSRs). 5 | 6 | - [Related Crypto Libraries](#related-crypto-libraries) 7 | - [Current Release](#current-release) 8 | - [Dependencies](#dependencies) 9 | - [Installation](#installation) 10 | - [License](#license) 11 | - [Documentation](#documentation) 12 | - [Continuous Integration](#continuous-integration) 13 | - [Testing](#testing) 14 | - [Development](#development) 15 | - [CI Tasks](#ci-tasks) 16 | 17 | [![GitHub Actions CI](https://github.com/wbond/csrbuilder/workflows/CI/badge.svg)](https://github.com/wbond/csrbuilder/actions?workflow=CI) 18 | [![CircleCI](https://circleci.com/gh/wbond/csrbuilder.svg?style=shield)](https://circleci.com/gh/wbond/csrbuilder) 19 | [![PyPI](https://img.shields.io/pypi/v/csrbuilder.svg)](https://pypi.python.org/pypi/csrbuilder) 20 | 21 | ## Related Crypto Libraries 22 | 23 | *csrbuilder* is part of the modularcrypto family of Python packages: 24 | 25 | - [asn1crypto](https://github.com/wbond/asn1crypto) 26 | - [oscrypto](https://github.com/wbond/oscrypto) 27 | - [csrbuilder](https://github.com/wbond/csrbuilder) 28 | - [certbuilder](https://github.com/wbond/certbuilder) 29 | - [crlbuilder](https://github.com/wbond/crlbuilder) 30 | - [ocspbuilder](https://github.com/wbond/ocspbuilder) 31 | - [certvalidator](https://github.com/wbond/certvalidator) 32 | 33 | ## Current Release 34 | 35 | 0.10.1 - [changelog](changelog.md) 36 | 37 | ## Dependencies 38 | 39 | - [*asn1crypto*](https://github.com/wbond/asn1crypto) 40 | - [*oscrypto*](https://github.com/wbond/oscrypto) 41 | - Python 2.6, 2.7, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9 or pypy 42 | 43 | ## Installation 44 | 45 | ```bash 46 | pip install csrbuilder 47 | ``` 48 | 49 | ## License 50 | 51 | *csrbuilder* is licensed under the terms of the MIT license. See the 52 | [LICENSE](LICENSE) file for the exact license text. 53 | 54 | ## Documentation 55 | 56 | [*csrbuilder* documentation](docs/readme.md) 57 | 58 | ## Continuous Integration 59 | 60 | - [macOS, Linux, Windows](https://github.com/wbond/csrbuilder/actions/workflows/ci.yml) via GitHub Actions 61 | - [arm64](https://circleci.com/gh/wbond/csrbuilder) via CircleCI 62 | 63 | ## Testing 64 | 65 | Tests are written using `unittest` and require no third-party packages. 66 | 67 | Depending on what type of source is available for the package, the following 68 | commands can be used to run the test suite. 69 | 70 | ### Git Repository 71 | 72 | When working within a Git working copy, or an archive of the Git repository, 73 | the full test suite is run via: 74 | 75 | ```bash 76 | python run.py tests 77 | ``` 78 | 79 | To run only some tests, pass a regular expression as a parameter to `tests`. 80 | 81 | ```bash 82 | python run.py tests build 83 | ``` 84 | 85 | ### PyPi Source Distribution 86 | 87 | When working within an extracted source distribution (aka `.tar.gz`) from 88 | PyPi, the full test suite is run via: 89 | 90 | ```bash 91 | python setup.py test 92 | ``` 93 | 94 | ## Development 95 | 96 | To install the package used for linting, execute: 97 | 98 | ```bash 99 | pip install --user -r requires/lint 100 | ``` 101 | 102 | The following command will run the linter: 103 | 104 | ```bash 105 | python run.py lint 106 | ``` 107 | 108 | Support for code coverage can be installed via: 109 | 110 | ```bash 111 | pip install --user -r requires/coverage 112 | ``` 113 | 114 | Coverage is measured by running: 115 | 116 | ```bash 117 | python run.py coverage 118 | ``` 119 | 120 | To install the packages requires to generate the API documentation, run: 121 | 122 | ```bash 123 | pip install --user -r requires/api_docs 124 | ``` 125 | 126 | The documentation can then be generated by running: 127 | 128 | ```bash 129 | python run.py api_docs 130 | ``` 131 | 132 | To change the version number of the package, run: 133 | 134 | ```bash 135 | python run.py version {pep440_version} 136 | ``` 137 | 138 | To install the necessary packages for releasing a new version on PyPI, run: 139 | 140 | ```bash 141 | pip install --user -r requires/release 142 | ``` 143 | 144 | Releases are created by: 145 | 146 | - Making a git tag in [PEP 440](https://www.python.org/dev/peps/pep-0440/#examples-of-compliant-version-schemes) format 147 | - Running the command: 148 | 149 | ```bash 150 | python run.py release 151 | ``` 152 | 153 | Existing releases can be found at https://pypi.org/project/csrbuilder. 154 | 155 | ## CI Tasks 156 | 157 | A task named `deps` exists to ensure a modern version of `pip` is installed, 158 | along with all necessary testing dependencies. 159 | 160 | The `ci` task runs `lint` (if flake8 is avaiable for the version of Python) and 161 | `coverage` (or `tests` if coverage is not available for the version of Python). 162 | If the current directory is a clean git working copy, the coverage data is 163 | submitted to codecov.io. 164 | 165 | ```bash 166 | python run.py deps 167 | python run.py ci 168 | ``` 169 | -------------------------------------------------------------------------------- /requires/api_docs: -------------------------------------------------------------------------------- 1 | CommonMark >= 0.6.0 2 | -------------------------------------------------------------------------------- /requires/ci: -------------------------------------------------------------------------------- 1 | setuptools == 36.8.0 ; python_version == '2.6' 2 | setuptools == 44.1.1 ; python_version == '2.7' and sys_platform == 'win32' 3 | setuptools == 18.4 ; python_version == '3.2' 4 | setuptools == 39.2.0 ; python_version == '3.3' 5 | https://github.com/wbond/asn1crypto/archive/master.zip 6 | https://github.com/wbond/oscrypto/archive/master.zip 7 | https://github.com/wbond/oscrypto/archive/master.zip#egg=oscrypto_tests&subdirectory=tests 8 | -r ./coverage 9 | -r ./lint 10 | # cffi 3.15.0 is required for Python 3.10 11 | cffi == 1.15.0 ; (python_version == '2.7' or python_version >= '3.6') and sys_platform == 'darwin' 12 | pycparser == 2.19 ; (python_version == '2.7' or python_version >= '3.6') and sys_platform == 'darwin' 13 | -------------------------------------------------------------------------------- /requires/coverage: -------------------------------------------------------------------------------- 1 | coverage == 4.4.1 ; python_version == '2.6' 2 | coverage == 4.5.4 ; python_version == '3.3' or python_version == '3.4' 3 | coverage == 5.5 ; python_version == '2.7' or python_version == '3.5' or python_version == '3.6' 4 | coverage == 6.5.0 ; python_version == '3.7' 5 | coverage == 7.3.0 ; python_version >= '3.8' 6 | -------------------------------------------------------------------------------- /requires/lint: -------------------------------------------------------------------------------- 1 | setuptools >= 39.0.1 ; python_version == '2.7' or python_version >= '3.3' 2 | enum34 == 1.1.6 ; python_version == '2.7' or python_version == '3.3' 3 | configparser == 3.5.0 ; python_version == '2.7' 4 | mccabe == 0.6.1 ; python_version == '3.3' 5 | pycodestyle == 2.3.1 ; python_version == '3.3' 6 | pyflakes == 1.6.0 ; python_version == '3.3' 7 | flake8 == 3.5.0 ; python_version == '3.3' 8 | mccabe == 0.6.1 ; python_version == '2.7' or python_version >= '3.4' 9 | pycodestyle == 2.5.0 ; python_version == '2.7' or python_version >= '3.4' 10 | pyflakes == 2.1.1 ; python_version == '2.7' or python_version >= '3.4' 11 | functools32 == 3.2.3-2 ; python_version == '2.7' 12 | typing == 3.7.4.1 ; python_version == '2.7' or python_version == '3.4' 13 | entrypoints == 0.3 ; python_version == '2.7' or python_version >= '3.4' 14 | flake8 == 3.7.9 ; python_version == '2.7' or python_version >= '3.4' 15 | -------------------------------------------------------------------------------- /requires/release: -------------------------------------------------------------------------------- 1 | wheel >= 0.31.0 2 | twine >= 1.11.0 3 | setuptools >= 38.6.0 4 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | from __future__ import unicode_literals, division, absolute_import, print_function 4 | 5 | from dev._task import run_task 6 | 7 | 8 | run_task() 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import os 3 | import shutil 4 | import sys 5 | import warnings 6 | 7 | import setuptools 8 | from setuptools import find_packages, setup, Command 9 | from setuptools.command.egg_info import egg_info 10 | 11 | 12 | PACKAGE_NAME = 'csrbuilder' 13 | PACKAGE_VERSION = '0.10.1' 14 | PACKAGE_ROOT = os.path.dirname(os.path.abspath(__file__)) 15 | 16 | 17 | # setuptools 38.6.0 and newer know about long_description_content_type, but 18 | # distutils still complains about it, so silence the warning 19 | sv = setuptools.__version__ 20 | svi = tuple(int(o) if o.isdigit() else o for o in sv.split('.')) 21 | if svi >= (38, 6): 22 | warnings.filterwarnings( 23 | 'ignore', 24 | "Unknown distribution option: 'long_description_content_type'", 25 | module='distutils.dist' 26 | ) 27 | 28 | 29 | # This allows us to send the LICENSE and docs when creating a sdist. Wheels 30 | # automatically include the LICENSE, and don't need the docs. For these 31 | # to be included, the command must be "python setup.py sdist". 32 | package_data = {} 33 | if sys.argv[1:] == ['sdist'] or sorted(sys.argv[1:]) == ['-q', 'sdist']: 34 | package_data[PACKAGE_NAME] = [ 35 | '../LICENSE', 36 | '../*.md', 37 | '../docs/*.md', 38 | ] 39 | 40 | 41 | # Ensures a copy of the LICENSE is included with the egg-info for 42 | # install and bdist_egg commands 43 | class EggInfoCommand(egg_info): 44 | def run(self): 45 | egg_info_path = os.path.join( 46 | PACKAGE_ROOT, 47 | '%s.egg-info' % PACKAGE_NAME 48 | ) 49 | if not os.path.exists(egg_info_path): 50 | os.mkdir(egg_info_path) 51 | shutil.copy2( 52 | os.path.join(PACKAGE_ROOT, 'LICENSE'), 53 | os.path.join(egg_info_path, 'LICENSE') 54 | ) 55 | egg_info.run(self) 56 | 57 | 58 | class CleanCommand(Command): 59 | user_options = [ 60 | ('all', 'a', '(Compatibility with original clean command)'), 61 | ] 62 | 63 | def initialize_options(self): 64 | self.all = False 65 | 66 | def finalize_options(self): 67 | pass 68 | 69 | def run(self): 70 | sub_folders = ['build', 'temp', '%s.egg-info' % PACKAGE_NAME] 71 | if self.all: 72 | sub_folders.append('dist') 73 | for sub_folder in sub_folders: 74 | full_path = os.path.join(PACKAGE_ROOT, sub_folder) 75 | if os.path.exists(full_path): 76 | shutil.rmtree(full_path) 77 | for root, dirs, files in os.walk(os.path.join(PACKAGE_ROOT, PACKAGE_NAME)): 78 | for filename in files: 79 | if filename[-4:] == '.pyc': 80 | os.unlink(os.path.join(root, filename)) 81 | for dirname in list(dirs): 82 | if dirname == '__pycache__': 83 | shutil.rmtree(os.path.join(root, dirname)) 84 | 85 | 86 | readme = '' 87 | with codecs.open(os.path.join(PACKAGE_ROOT, 'readme.md'), 'r', 'utf-8') as f: 88 | readme = f.read() 89 | 90 | 91 | setup( 92 | name=PACKAGE_NAME, 93 | version=PACKAGE_VERSION, 94 | 95 | description='Creates and signs X.509 certificate signing requests (CSRs)', 96 | long_description=readme, 97 | long_description_content_type='text/markdown', 98 | 99 | url='https://github.com/wbond/csrbuilder', 100 | 101 | author='wbond', 102 | author_email='will@wbond.net', 103 | 104 | license='MIT', 105 | 106 | classifiers=[ 107 | 'Development Status :: 4 - Beta', 108 | 109 | 'Intended Audience :: Developers', 110 | 111 | 'License :: OSI Approved :: MIT License', 112 | 113 | 'Programming Language :: Python :: 2', 114 | 'Programming Language :: Python :: 2.6', 115 | 'Programming Language :: Python :: 2.7', 116 | 'Programming Language :: Python :: 3', 117 | 'Programming Language :: Python :: 3.2', 118 | 'Programming Language :: Python :: 3.3', 119 | 'Programming Language :: Python :: 3.4', 120 | 'Programming Language :: Python :: 3.5', 121 | 'Programming Language :: Python :: 3.6', 122 | 'Programming Language :: Python :: 3.7', 123 | 'Programming Language :: Python :: 3.8', 124 | 'Programming Language :: Python :: 3.9', 125 | 'Programming Language :: Python :: Implementation :: PyPy', 126 | 127 | 'Topic :: Security :: Cryptography', 128 | ], 129 | 130 | keywords='crypto pki x509 certificate csr', 131 | 132 | install_requires=[ 133 | 'asn1crypto>=1.2.0', 134 | 'oscrypto>=1.1.0' 135 | ], 136 | packages=[PACKAGE_NAME], 137 | package_data=package_data, 138 | 139 | test_suite='tests.make_suite', 140 | 141 | cmdclass={ 142 | 'clean': CleanCommand, 143 | 'egg_info': EggInfoCommand, 144 | } 145 | ) 146 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import os 5 | import sys 6 | import unittest 7 | 8 | if sys.version_info < (3, 5): 9 | import imp 10 | else: 11 | import importlib 12 | import importlib.abc 13 | import importlib.util 14 | 15 | 16 | if sys.version_info >= (3, 5): 17 | class ModCryptoMetaFinder(importlib.abc.MetaPathFinder): 18 | def setup(self): 19 | self.modules = {} 20 | sys.meta_path.insert(0, self) 21 | 22 | def add_module(self, package_name, package_path): 23 | if package_name not in self.modules: 24 | self.modules[package_name] = package_path 25 | 26 | def find_spec(self, fullname, path, target=None): 27 | name_parts = fullname.split('.') 28 | if name_parts[0] not in self.modules: 29 | return None 30 | 31 | package = name_parts[0] 32 | package_path = self.modules[package] 33 | 34 | fullpath = os.path.join(package_path, *name_parts[1:]) 35 | 36 | if os.path.isdir(fullpath): 37 | filename = os.path.join(fullpath, "__init__.py") 38 | submodule_locations = [fullpath] 39 | else: 40 | filename = fullpath + ".py" 41 | submodule_locations = None 42 | 43 | if not os.path.exists(filename): 44 | return None 45 | 46 | return importlib.util.spec_from_file_location( 47 | fullname, 48 | filename, 49 | loader=None, 50 | submodule_search_locations=submodule_locations 51 | ) 52 | 53 | CUSTOM_FINDER = ModCryptoMetaFinder() 54 | CUSTOM_FINDER.setup() 55 | 56 | 57 | def _import_from(mod, path, mod_dir=None): 58 | """ 59 | Imports a module from a specific path 60 | 61 | :param mod: 62 | A unicode string of the module name 63 | 64 | :param path: 65 | A unicode string to the directory containing the module 66 | 67 | :param mod_dir: 68 | If the sub directory of "path" is different than the "mod" name, 69 | pass the sub directory as a unicode string 70 | 71 | :return: 72 | None if not loaded, otherwise the module 73 | """ 74 | 75 | if mod in sys.modules: 76 | return sys.modules[mod] 77 | 78 | if mod_dir is None: 79 | full_mod = mod 80 | else: 81 | full_mod = mod_dir.replace(os.sep, '.') 82 | 83 | if mod_dir is None: 84 | mod_dir = mod.replace('.', os.sep) 85 | 86 | if not os.path.exists(path): 87 | return None 88 | 89 | source_path = os.path.join(path, mod_dir, '__init__.py') 90 | if not os.path.exists(source_path): 91 | source_path = os.path.join(path, mod_dir + '.py') 92 | 93 | if not os.path.exists(source_path): 94 | return None 95 | 96 | if os.sep in mod_dir: 97 | append, mod_dir = mod_dir.rsplit(os.sep, 1) 98 | path = os.path.join(path, append) 99 | 100 | try: 101 | if sys.version_info < (3, 5): 102 | mod_info = imp.find_module(mod_dir, [path]) 103 | return imp.load_module(mod, *mod_info) 104 | 105 | else: 106 | package = mod.split('.', 1)[0] 107 | package_dir = full_mod.split('.', 1)[0] 108 | package_path = os.path.join(path, package_dir) 109 | CUSTOM_FINDER.add_module(package, package_path) 110 | 111 | return importlib.import_module(mod) 112 | 113 | except ImportError: 114 | return None 115 | 116 | 117 | def make_suite(): 118 | """ 119 | Constructs a unittest.TestSuite() of all tests for the package. For use 120 | with setuptools. 121 | 122 | :return: 123 | A unittest.TestSuite() object 124 | """ 125 | 126 | loader = unittest.TestLoader() 127 | suite = unittest.TestSuite() 128 | for test_class in test_classes(): 129 | tests = loader.loadTestsFromTestCase(test_class) 130 | suite.addTests(tests) 131 | return suite 132 | 133 | 134 | def test_classes(): 135 | """ 136 | Returns a list of unittest.TestCase classes for the package 137 | 138 | :return: 139 | A list of unittest.TestCase classes 140 | """ 141 | 142 | # Make sure the module is loaded from this source folder 143 | tests_dir = os.path.dirname(os.path.abspath(__file__)) 144 | 145 | _import_from( 146 | 'csrbuilder', 147 | os.path.join(tests_dir, '..') 148 | ) 149 | 150 | from .test_csrbuilder import CSRBuilderTests 151 | 152 | return [CSRBuilderTests] 153 | -------------------------------------------------------------------------------- /tests/test_csrbuilder.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import unittest 5 | import os 6 | 7 | import asn1crypto.csr 8 | from asn1crypto.util import OrderedDict 9 | from oscrypto import asymmetric 10 | from csrbuilder import CSRBuilder 11 | 12 | 13 | tests_root = os.path.dirname(__file__) 14 | fixtures_dir = os.path.join(tests_root, 'fixtures') 15 | 16 | 17 | class CSRBuilderTests(unittest.TestCase): 18 | 19 | def test_build_basic(self): 20 | public_key, private_key = asymmetric.generate_pair('ec', curve='secp256r1') 21 | 22 | builder = CSRBuilder( 23 | { 24 | 'country_name': 'US', 25 | 'state_or_province_name': 'Massachusetts', 26 | 'locality_name': 'Newbury', 27 | 'organization_name': 'Codex Non Sufficit LC', 28 | 'common_name': 'Will Bond', 29 | }, 30 | public_key 31 | ) 32 | builder.subject_alt_domains = ['codexns.io', 'codexns.com'] 33 | request = builder.build(private_key) 34 | der_bytes = request.dump() 35 | 36 | new_request = asn1crypto.csr.CertificationRequest.load(der_bytes) 37 | cri = new_request['certification_request_info'] 38 | 39 | self.assertEqual('sha256_ecdsa', new_request['signature_algorithm']['algorithm'].native) 40 | self.assertEqual(1, len(cri['attributes'])) 41 | self.assertEqual('extension_request', cri['attributes'][0]['type'].native) 42 | 43 | extensions = cri['attributes'][0]['values'][0] 44 | self.assertEqual(4, len(extensions)) 45 | 46 | self.assertEqual('basic_constraints', extensions[0]['extn_id'].native) 47 | self.assertEqual( 48 | OrderedDict([('ca', False), ('path_len_constraint', None)]), 49 | extensions[0]['extn_value'].native 50 | ) 51 | 52 | self.assertEqual('extended_key_usage', extensions[1]['extn_id'].native) 53 | self.assertEqual( 54 | ['server_auth', 'client_auth'], 55 | extensions[1]['extn_value'].native 56 | ) 57 | 58 | self.assertEqual('key_usage', extensions[2]['extn_id'].native) 59 | self.assertEqual( 60 | set(['digital_signature', 'key_encipherment']), 61 | extensions[2]['extn_value'].native 62 | ) 63 | 64 | self.assertEqual('subject_alt_name', extensions[3]['extn_id'].native) 65 | self.assertEqual( 66 | ['codexns.io', 'codexns.com'], 67 | extensions[3]['extn_value'].native 68 | ) 69 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py26,py27,py32,py33,py34,py35,py36,py37,py38,py39,pypy 3 | 4 | [testenv] 5 | deps = -rrequires/ci 6 | commands = {envpython} run.py ci 7 | 8 | [pep8] 9 | max-line-length = 120 10 | 11 | [flake8] 12 | max-line-length = 120 13 | jobs = 1 14 | --------------------------------------------------------------------------------