├── .gitignore ├── .travis.yml ├── LICENSE ├── README.rst ├── appveyor.yml ├── bitbucket-pipelines.yml ├── dirspec ├── __init__.py ├── basedir.py ├── tests │ ├── __init__.py │ ├── test_basedir.py │ └── test_utils.py └── utils.py ├── ez_setup.py ├── geofrontcli ├── __init__.py ├── cli.py ├── client.py ├── key.py ├── ssl.py └── version.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── cli_test.py ├── client_test.py ├── key_test.py └── ssl_test.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | .*.swo 4 | .*.swp 5 | .cache 6 | .coverage 7 | .tox 8 | build 9 | dist 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | matrix: 3 | include: 4 | - os: osx 5 | osx_image: xcode8.1 6 | # See also: https://github.com/Homebrew/homebrew-core/issues/6949 7 | - os: linux 8 | language: python 9 | python: pypy-5.3.1 10 | - os: linux 11 | language: python 12 | python: 2.7 13 | - os: linux 14 | language: python 15 | python: 3.3 16 | - os: linux 17 | language: python 18 | python: 3.4 19 | - os: linux 20 | language: python 21 | python: 3.5 22 | - os: linux 23 | language: python 24 | python: 3.6 25 | install: 26 | | 27 | if [[ "$TRAVIS_OS_NAME" = "linux" ]]; then 28 | pip install --upgrade pip setuptools tox-travis; 29 | elif [[ "$TRAVIS_OS_NAME" = "osx" ]]; then 30 | brew tap drolando/homebrew-deadsnakes; 31 | brew install python33 python34 python35 python3 pypy 32 | pip install --upgrade pip setuptools 33 | pip install --user tox; 34 | fi 35 | script: tox 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Geofront CLI client 2 | =================== 3 | 4 | .. image:: https://badge.fury.io/py/geofront-cli.svg? 5 | :target: https://pypi.python.org/pypi/geofront-cli 6 | :alt: Latest PyPI version 7 | 8 | .. image:: https://travis-ci.org/spoqa/geofront-cli.svg? 9 | :target: https://travis-ci.org/spoqa/geofront-cli 10 | :alt: Build status (Travis CI) 11 | 12 | .. image:: https://ci.appveyor.com/api/projects/status/wjcgay1b4twffwbc?svg=true 13 | :target: https://ci.appveyor.com/project/dahlia/geofront-cli 14 | :alt: Build status (AppVeyor) 15 | 16 | It provides a CLI client for Geofront_, a simple SSH key management server. 17 | 18 | .. _Geofront: https://geofront.readthedocs.org/ 19 | 20 | 21 | Installation 22 | ------------ 23 | 24 | It is available on PyPI__, so you can install it using ``pip`` installer. 25 | We, however, recommend to use pipsi_ instead so that geofront-cli and its 26 | dependencies don't make your global site-packages messy. 27 | 28 | .. code-block:: console 29 | 30 | $ pipsi install geofront-cli 31 | 32 | __ https://pypi.python.org/pypi/geofront-cli 33 | .. _pipsi: https://github.com/mitsuhiko/pipsi 34 | 35 | 36 | Getting started 37 | --------------- 38 | 39 | What you have to do first of all is to configure the Geofront server URL. 40 | Type ``geofront-cli start`` and then it will show a prompt: 41 | 42 | .. code-block:: console 43 | 44 | $ geofront-cli start 45 | Geofront server URL: 46 | 47 | We suppose ``http://example.com/`` here. It will open an authentication 48 | page in your default web browser: 49 | 50 | .. code-block:: console 51 | 52 | $ geofront-cli start 53 | Geofront server URL: http://example.com/ 54 | Continue to authenticate in your web browser... 55 | Press return to continue 56 | 57 | 58 | List available remotes 59 | ---------------------- 60 | 61 | You can list the available remotes using ``geofront-cli remotes`` command: 62 | 63 | .. code-block:: console 64 | 65 | $ geofront-cli remotes 66 | web-1 67 | web-2 68 | web-3 69 | worker-1 70 | worker-2 71 | db-1 72 | db-2 73 | 74 | If you give ``-v``/``--verbose`` option it will show their actual addresses 75 | as well: 76 | 77 | .. code-block:: console 78 | 79 | $ geofront-cli remotes -v 80 | web-1 ubuntu@192.168.0.5 81 | web-2 ubuntu@192.168.0.6 82 | web-3 ubuntu@192.168.0.7 83 | worker-1 ubuntu@192.168.0.25 84 | worker-2 ubuntu@192.168.0.26 85 | db-1 ubuntu@192.168.0.50 86 | db-2 ubuntu@192.168.0.51 87 | 88 | 89 | SSH to remote 90 | ------------- 91 | 92 | You can easily connect to a remote through SSH. Use ``geofront-cli ssh`` 93 | command instead of vanilla ``ssh``: 94 | 95 | .. code-block:: console 96 | 97 | $ geofront-cli ssh web-1 98 | Welcome to Ubuntu 12.04.3 LTS (GNU/Linux 2.6.32-042stab078.27 i686) 99 | 100 | * Documentation: https://help.ubuntu.com/ 101 | ubuntu@web-1:~$ 102 | 103 | In most cases, you probably need to list remotes to find an alias to SSH 104 | before run ``geofront-cli ssh`` command. ``geofront-cli go`` command is 105 | a single command for these two actions at once: 106 | 107 | .. code-block:: console 108 | 109 | $ geofront-cli go 110 | (...interactive fuzzy finder for remotes is shown...) 111 | Welcome to Ubuntu 12.04.3 LTS (GNU/Linux 2.6.32-042stab078.27 i686) 112 | 113 | * Documentation: https://help.ubuntu.com/ 114 | ubuntu@web-1:~$ 115 | 116 | Note that there's a shortcut command ``gfg`` which is an alias of 117 | ``geofront-cli go``. 118 | 119 | There is ``geofront-cli scp`` command as well, which is corresponding 120 | to ``scp``: 121 | 122 | .. code-block:: console 123 | 124 | $ geofront-cli scp file.txt web-1:file.txt 125 | file.txt 100% 3157 3.1KB/s 00:00 126 | $ geofront-cli scp -r web-1:path/etc/apt ./ 127 | sources.list 100% 3157 3.1KB/s 00:00 128 | trusted.gpg 100% 14KB 13.9KB/s 00:00 129 | 130 | 131 | Missing features 132 | ---------------- 133 | 134 | - Shortcut for ``geofront-cli ssh`` command 135 | - Make ``geofront-cli ssh`` similar to ``ssh`` 136 | - Autocompletion 137 | 138 | 139 | Author and license 140 | ------------------ 141 | 142 | `Hong Minhee`__ wrote geofront-cli, and Spoqa_ maintains it. 143 | It is licensed under GPLv3_ or later. 144 | 145 | __ https://hongminhee.org/ 146 | .. _Spoqa: http://www.spoqa.com/ 147 | .. _GPLv3: http://www.gnu.org/licenses/gpl-3.0.html 148 | 149 | 150 | Changelog 151 | --------- 152 | 153 | Version 0.4.5 154 | ````````````` 155 | 156 | To be released. 157 | 158 | 159 | Version 0.4.4 160 | ````````````` 161 | 162 | Released on April 03, 2020. 163 | 164 | - Fixed some command won't work properly. 165 | This bug occured when running ssh or scp command through the other command. 166 | (e.g. `geofront-cli go`) [`#19`__ by cynthia] 167 | 168 | __ https://github.com/spoqa/geofront-cli/pull/19 169 | 170 | Version 0.4.3 171 | ````````````` 172 | 173 | Released on March 25, 2020. 174 | 175 | - Added jump host options to use ProxyJump in SSH. [`#18`__ by cynthia] 176 | 177 | __ https://github.com/spoqa/geofront-cli/pull/18 178 | 179 | 180 | Version 0.4.2 181 | ````````````` 182 | 183 | Released on February 26, 2020. 184 | 185 | - Added supporting for LibreSSL. [`#16`__ by cynthia] 186 | 187 | __ https://github.com/spoqa/geofront-cli/pull/16 188 | 189 | 190 | Version 0.4.1 191 | ````````````` 192 | 193 | Released on May 24, 2017. 194 | 195 | - Fixed a bug that ``geofront-cli go``/``gfg`` had crashed with 196 | ``AttributeError`` when a user cancelled (i.e. Ctrl-C) to select a remote. 197 | [`#10`__] 198 | 199 | __ https://github.com/spoqa/geofront-cli/issues/10 200 | 201 | 202 | Version 0.4.0 203 | ````````````` 204 | 205 | Released on May 23, 2017. 206 | 207 | - Dropped support of Python 2.6 and 3.2. 208 | - ``geofront-cli go`` command and its alias shortcut ``gfg`` were introduced. 209 | It's an interactive user interface to select a remote and SSH to it at once. 210 | - Fixed verification failure of SSL certificates when Python was installed 211 | using Homebrew on macOS. Now it depends on Certifi_. 212 | - Now the output list of ``geofront-cli remotes`` is sorted. 213 | - The second column of ``geofront-cli remotes --verbose`` result became 214 | vertically aligned. 215 | - The second column of ``geofront-cli remotes --verbose`` result became 216 | to omit the port number if it's 22 so that these are easy to copy-and-paste 217 | into other SSH programs. 218 | - Loading spinners became shown when time-taking tasks are running. 219 | 220 | .. _Certifi: https://github.com/certifi/python-certifi 221 | 222 | 223 | Version 0.3.4 224 | ````````````` 225 | 226 | Released on April 3, 2017. 227 | 228 | - Fixed ``UnicodeError`` during signing the running Python 3 executable 229 | on macOS. 230 | 231 | 232 | Version 0.3.3 233 | ````````````` 234 | 235 | Released on March 30, 2017. 236 | 237 | - Now ``-d``/``--debug`` option prints more debug logs. 238 | - Fixed `system errors during getting/setting password through keyring/Keychain 239 | on macOS due to some unsigned Python executables`__. 240 | 241 | __ https://github.com/jaraco/keyring/issues/219 242 | 243 | 244 | Version 0.3.2 245 | ````````````` 246 | 247 | Released on May 31, 2016. 248 | 249 | - Fixed ``ImportError`` on Python 2.6. 250 | 251 | 252 | Version 0.3.1 253 | ````````````` 254 | 255 | Released on May 28, 2016. 256 | 257 | - Forward compatibility with Geofront 0.4. 258 | 259 | 260 | Version 0.3.0 261 | ````````````` 262 | 263 | Released on January 15, 2016. 264 | 265 | - Fixed an ``AttributeError`` during handling error sent by server. 266 | [`#4`__] 267 | 268 | __ https://github.com/spoqa/geofront-cli/issues/4 269 | 270 | 271 | Version 0.2.2 272 | ````````````` 273 | 274 | Released on November 14, 2014. 275 | 276 | - Added ``-v``/``--version`` option. 277 | - Fixed an ``AttributeError`` during handling error from server. 278 | [`#2`__, `#3`__ by Lee Jaeyoung] 279 | 280 | __ https://github.com/spoqa/geofront-cli/issues/2 281 | __ https://github.com/spoqa/geofront-cli/pull/3 282 | 283 | 284 | Version 0.2.1 285 | ````````````` 286 | 287 | Released on June 29, 2014. 288 | 289 | - Added ``geofront-cli scp`` command. 290 | - Added the short option ``-S`` for ``--ssh``. 291 | - It becomes to no more depend on dirspec_. Instead it's simply bundled 292 | together. 293 | - ``geofront-cli`` now prints a usage description when no subcommand specified. 294 | 295 | .. _dirspec: https://pypi.python.org/pypi/dirspec 296 | 297 | 298 | Version 0.2.0 299 | ````````````` 300 | 301 | Released on May 3, 2014. 302 | 303 | - Added handling of unfinished authentication error. 304 | - Added handling of incompatible protocol version. 305 | 306 | 307 | Version 0.1.1 308 | ````````````` 309 | 310 | Released on April 22, 2014. 311 | 312 | - Fixed Python 2 incompatibility. 313 | - Added warning for non-SSL server URL. 314 | 315 | 316 | Version 0.1.0 317 | ````````````` 318 | 319 | First pre-alpha release. Released on April 21, 2014. 320 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | build: off 2 | shallow_clone: true 3 | install: 4 | - C:\Python27-x64\Scripts\pip.exe install tox 5 | test_script: 6 | - C:\Python27-x64\Scripts\tox.exe --skip-missing-interpreters 7 | -------------------------------------------------------------------------------- /bitbucket-pipelines.yml: -------------------------------------------------------------------------------- 1 | image: themattrix/tox 2 | pipelines: 3 | default: 4 | - step: 5 | script: 6 | - tox 7 | -------------------------------------------------------------------------------- /dirspec/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2011 Canonical Ltd. 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Lesser General Public License version 3 7 | # as published by the Free Software Foundation. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Lesser General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Lesser General Public License 15 | # along with this program. If not, see . 16 | """dirspec package.""" 17 | -------------------------------------------------------------------------------- /dirspec/basedir.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2011-2012 Canonical Ltd. 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Lesser General Public License version 3 7 | # as published by the Free Software Foundation. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Lesser General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Lesser General Public License 15 | # along with this program. If not, see . 16 | """XDG Base Directory paths.""" 17 | 18 | from __future__ import unicode_literals, print_function 19 | 20 | import os 21 | 22 | from dirspec.utils import (default_cache_home, 23 | default_config_path, default_config_home, 24 | default_data_path, default_data_home, 25 | get_env_path, unicode_path) 26 | 27 | __all__ = ['xdg_cache_home', 28 | 'xdg_config_home', 29 | 'xdg_data_home', 30 | 'xdg_config_dirs', 31 | 'xdg_data_dirs', 32 | 'load_config_paths', 33 | 'load_data_paths', 34 | 'load_first_config', 35 | 'save_config_path', 36 | 'save_data_path', 37 | ] 38 | 39 | 40 | def get_xdg_cache_home(): 41 | """Get the path for XDG cache directory in user's HOME.""" 42 | return get_env_path('XDG_CACHE_HOME', default_cache_home) 43 | 44 | 45 | def get_xdg_config_home(): 46 | """Get the path for XDG config directory in user's HOME.""" 47 | return get_env_path('XDG_CONFIG_HOME', default_config_home) 48 | 49 | 50 | def get_xdg_data_home(): 51 | """Get the path for XDG data directory in user's HOME.""" 52 | return get_env_path('XDG_DATA_HOME', default_data_home) 53 | 54 | 55 | def get_xdg_config_dirs(): 56 | """Get the paths for the XDG config directories.""" 57 | result = [get_xdg_config_home()] 58 | result.extend([x.encode('utf-8') for x in get_env_path( 59 | 'XDG_CONFIG_DIRS', 60 | default_config_path).decode('utf-8').split(os.pathsep)]) 61 | return result 62 | 63 | 64 | def get_xdg_data_dirs(): 65 | """Get the paths for the XDG data directories.""" 66 | result = [get_xdg_data_home()] 67 | result.extend([x.encode('utf-8') for x in get_env_path( 68 | 'XDG_DATA_DIRS', 69 | default_data_path).decode('utf-8').split(os.pathsep)]) 70 | return result 71 | 72 | 73 | def load_config_paths(*resource): 74 | """Iterator of configuration paths. 75 | 76 | Return an iterator which gives each directory named 'resource' in 77 | the configuration search path. Information provided by earlier 78 | directories should take precedence over later ones (ie, the user's 79 | config dir comes first). 80 | """ 81 | resource = os.path.join(*resource) 82 | assert not resource.startswith('/') 83 | for config_dir in get_xdg_config_dirs(): 84 | path = os.path.join(config_dir, resource.encode('utf-8')) 85 | # access the file system always with unicode 86 | # to properly behave in some operating systems 87 | if os.path.exists(unicode_path(path)): 88 | yield path 89 | 90 | 91 | def load_data_paths(*resource): 92 | """Iterator of data paths. 93 | 94 | Return an iterator which gives each directory named 'resource' in 95 | the stored data search path. Information provided by earlier 96 | directories should take precedence over later ones. 97 | """ 98 | resource = os.path.join(*resource) 99 | assert not resource.startswith('/') 100 | for data_dir in get_xdg_data_dirs(): 101 | path = os.path.join(data_dir, resource.encode('utf-8')) 102 | # access the file system always with unicode 103 | # to properly behave in some operating systems 104 | if os.path.exists(unicode_path(path)): 105 | yield path 106 | 107 | 108 | def load_first_config(*resource): 109 | """Returns the first result from load_config_paths, or None if nothing 110 | is found to load. 111 | """ 112 | for path in load_config_paths(*resource): 113 | return path 114 | return None 115 | 116 | 117 | def save_config_path(*resource): 118 | """Path to save configuration. 119 | 120 | Ensure $XDG_CONFIG_HOME// exists, and return its path. 121 | 'resource' should normally be the name of your application. Use this 122 | when SAVING configuration settings. Use the xdg_config_dirs variable 123 | for loading. 124 | """ 125 | resource = os.path.join(*resource) 126 | assert not resource.startswith('/') 127 | path = os.path.join(get_xdg_config_home(), resource.encode('utf-8')) 128 | # access the file system always with unicode 129 | # to properly behave in some operating systems 130 | if not os.path.isdir(unicode_path(path)): 131 | os.makedirs(unicode_path(path), 0o700) 132 | return path 133 | 134 | 135 | def save_data_path(*resource): 136 | """Path to save data. 137 | 138 | Ensure $XDG_DATA_HOME// exists, and return its path. 139 | 'resource' should normally be the name of your application. Use this 140 | when STORING a resource. Use the xdg_data_dirs variable for loading. 141 | """ 142 | resource = os.path.join(*resource) 143 | assert not resource.startswith('/') 144 | path = os.path.join(get_xdg_data_home(), resource.encode('utf-8')) 145 | # access the file system always with unicode 146 | # to properly behave in some operating systems 147 | if not os.path.isdir(unicode_path(path)): 148 | os.makedirs(unicode_path(path), 0o700) 149 | return path 150 | 151 | 152 | # pylint: disable=C0103 153 | xdg_cache_home = get_xdg_cache_home() 154 | xdg_config_home = get_xdg_config_home() 155 | xdg_data_home = get_xdg_data_home() 156 | 157 | xdg_config_dirs = [x for x in get_xdg_config_dirs() if x] 158 | xdg_data_dirs = [x for x in get_xdg_data_dirs() if x] 159 | # pylint: disable=C0103 160 | -------------------------------------------------------------------------------- /dirspec/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2011-2012 Canonical Ltd. 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Lesser General Public License version 3 7 | # as published by the Free Software Foundation. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Lesser General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Lesser General Public License 15 | # along with this program. If not, see . 16 | """"dirspec tests.""" 17 | 18 | from __future__ import unicode_literals, print_function 19 | 20 | import os 21 | 22 | from operator import setitem 23 | from testtools.testcase import TestCase 24 | 25 | 26 | class BaseTestCase(TestCase): 27 | """Base test case for dirspect tests.""" 28 | 29 | def assert_utf8_bytes(self, value): 30 | """Check that 'value' is a bytes sequence encoded with utf-8.""" 31 | self.assertIsInstance(value, bytes) 32 | try: 33 | value.decode('utf-8') 34 | except UnicodeError: 35 | self.fail('%r should be a utf8 encoded string.' % value) 36 | 37 | def tweak_env(self, envvar, value): 38 | """Tweak the environment variable %var to %value. 39 | 40 | Restore the old value when finished. 41 | """ 42 | old_val = os.environ.get(envvar, None) 43 | 44 | if old_val is None: 45 | self.addCleanup(os.environ.pop, envvar, None) 46 | else: 47 | self.addCleanup(setitem, os.environ, envvar, old_val) 48 | if value is None: 49 | os.environ.pop(envvar, None) 50 | else: 51 | os.environ[envvar] = value 52 | -------------------------------------------------------------------------------- /dirspec/tests/test_basedir.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2011-2012 Canonical Ltd. 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Lesser General Public License version 3 7 | # as published by the Free Software Foundation. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Lesser General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Lesser General Public License 15 | # along with this program. If not, see . 16 | """Tests for the base directory implementation.""" 17 | 18 | from __future__ import unicode_literals, print_function 19 | 20 | import os 21 | 22 | from dirspec import basedir 23 | from dirspec.tests import BaseTestCase 24 | 25 | 26 | class BasedirTestCase(BaseTestCase): 27 | """Tests for XDG Base Directory paths implementation.""" 28 | 29 | def test_cache_home(self): 30 | """Test that XDG_CACHE_HOME is handled correctly.""" 31 | self.tweak_env('XDG_CACHE_HOME', 32 | os.path.abspath(os.path.join(os.getcwd(), 33 | '_trial_temp', 34 | 'cache'))) 35 | self.assertEqual(os.environ['XDG_CACHE_HOME'].encode('utf-8'), 36 | basedir.get_xdg_cache_home()) 37 | 38 | def test_config_dirs(self): 39 | """Test that XDG_CONFIG_HOME is handled correctly.""" 40 | self.tweak_env('XDG_CONFIG_HOME', 41 | os.path.abspath(os.path.join(os.getcwd(), 42 | '_trial_temp', 43 | 'config'))) 44 | self.tweak_env('XDG_CONFIG_DIRS', os.pathsep.join(['etc'])) 45 | self.assertEqual([os.environ['XDG_CONFIG_HOME'].encode('utf-8'), 46 | b'etc'], 47 | basedir.get_xdg_config_dirs()) 48 | 49 | def test_config_home(self): 50 | """Test that XDG_CONFIG_DIRS is handled correctly.""" 51 | self.tweak_env('XDG_CONFIG_HOME', 52 | os.path.abspath(os.path.join(os.getcwd(), 53 | '_trial_temp', 54 | 'config'))) 55 | self.assertEqual(os.environ['XDG_CONFIG_HOME'].encode('utf-8'), 56 | basedir.get_xdg_config_home()) 57 | 58 | def test_data_dirs(self): 59 | """Test that XDG_DATA_HOME is handled correctly.""" 60 | self.tweak_env('XDG_DATA_HOME', 61 | os.path.abspath(os.path.join(os.getcwd(), 62 | '_trial_temp', 63 | 'xdg_data'))) 64 | self.tweak_env('XDG_DATA_DIRS', os.pathsep.join(['foo', 'bar'])) 65 | self.assertEqual([os.environ['XDG_DATA_HOME'].encode('utf-8'), 66 | b'foo', b'bar'], 67 | basedir.get_xdg_data_dirs()) 68 | 69 | def test_data_home(self): 70 | """Test that XDG_DATA_HOME is handled correctly.""" 71 | self.tweak_env('XDG_DATA_HOME', 72 | os.path.abspath(os.path.join(os.getcwd(), 73 | '_trial_temp', 74 | 'xdg_data'))) 75 | self.assertEqual(os.environ['XDG_DATA_HOME'].encode('utf-8'), 76 | basedir.get_xdg_data_home()) 77 | 78 | def test_default_cache_home(self): 79 | """Ensure default values work correctly.""" 80 | self.tweak_env('XDG_CACHE_HOME', None) 81 | expected = b'/blah' 82 | self.patch(basedir, 'default_cache_home', expected) 83 | self.assertFalse(os.environ.get('XDG_CACHE_HOME', False)) 84 | self.assertEqual(basedir.get_xdg_cache_home(), expected) 85 | 86 | def test_default_config_dirs(self): 87 | """Ensure default values work correctly.""" 88 | self.tweak_env('XDG_CONFIG_DIRS', None) 89 | self.tweak_env('XDG_CONFIG_HOME', None) 90 | expected = b'/blah' 91 | self.patch(basedir, 'default_config_home', expected) 92 | self.patch(basedir, 'default_config_path', '') 93 | self.assertFalse(os.environ.get('XDG_CONFIG_DIRS', False)) 94 | self.assertFalse(os.environ.get('XDG_CONFIG_HOME', False)) 95 | self.assertEqual(basedir.get_xdg_config_dirs(), [expected, b'']) 96 | 97 | def test_default_config_home(self): 98 | """Ensure default values work correctly.""" 99 | self.tweak_env('XDG_CONFIG_HOME', None) 100 | expected = b'/blah' 101 | self.patch(basedir, 'default_config_home', expected) 102 | self.assertFalse(os.environ.get('XDG_CONFIG_HOME', False)) 103 | self.assertEqual(basedir.get_xdg_config_home(), expected) 104 | 105 | def test_default_data_dirs(self): 106 | """Ensure default values work correctly.""" 107 | self.tweak_env('XDG_DATA_DIRS', None) 108 | self.tweak_env('XDG_DATA_HOME', None) 109 | expected = b'/blah' 110 | self.patch(basedir, 'default_data_home', expected) 111 | self.patch(basedir, 'default_data_path', '') 112 | self.assertFalse(os.environ.get('XDG_DATA_DIRS', False)) 113 | self.assertFalse(os.environ.get('XDG_DATA_HOME', False)) 114 | self.assertEqual(basedir.get_xdg_data_dirs(), [expected, b'']) 115 | 116 | def test_default_data_home(self): 117 | """Ensure default values work correctly.""" 118 | self.tweak_env('XDG_DATA_HOME', None) 119 | expected = b'/blah' 120 | self.patch(basedir, 'default_data_home', expected) 121 | self.assertFalse(os.environ.get('XDG_DATA_HOME', False)) 122 | self.assertEqual(basedir.get_xdg_data_home(), expected) 123 | 124 | def test_xdg_cache_home_is_utf8_bytes(self): 125 | """The returned path is bytes.""" 126 | actual = basedir.xdg_cache_home 127 | self.assert_utf8_bytes(actual) 128 | 129 | def test_xdg_config_home_is_utf8_bytes(self): 130 | """The returned path is bytes.""" 131 | actual = basedir.xdg_config_home 132 | self.assert_utf8_bytes(actual) 133 | 134 | def test_xdg_config_dirs_are_bytes(self): 135 | """The returned path is bytes.""" 136 | result = basedir.xdg_config_dirs 137 | for actual in result: 138 | self.assert_utf8_bytes(actual) 139 | 140 | def test_xdg_data_home_is_utf8_bytes(self): 141 | """The returned path is bytes.""" 142 | actual = basedir.xdg_data_home 143 | self.assert_utf8_bytes(actual) 144 | 145 | def test_xdg_data_dirs_are_bytes(self): 146 | """The returned path is bytes.""" 147 | result = basedir.xdg_data_dirs 148 | for actual in result: 149 | self.assert_utf8_bytes(actual) 150 | 151 | def test_load_config_paths_filter(self): 152 | """Since those folders don't exist, this should be empty.""" 153 | self.assertEqual(list(basedir.load_config_paths("x")), []) 154 | 155 | def test_save_config_path(self): 156 | """The path should end with xdg_config/x (respecting the separator).""" 157 | self.tweak_env('XDG_CONFIG_HOME', 'config_home') 158 | self.patch(os, "makedirs", lambda *args: None) 159 | result = basedir.save_config_path("x") 160 | self.assertEqual(result.decode('utf-8').split(os.sep)[-2:], 161 | ['config_home', 'x']) 162 | -------------------------------------------------------------------------------- /dirspec/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2011-2012 Canonical Ltd. 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Lesser General Public License version 3 7 | # as published by the Free Software Foundation. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Lesser General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Lesser General Public License 15 | # along with this program. If not, see . 16 | """Tests for utilities for the base directory implementation.""" 17 | 18 | from __future__ import unicode_literals, print_function 19 | 20 | import os 21 | import sys 22 | 23 | from dirspec import basedir, utils as dirutils 24 | from dirspec.utils import (get_env_path, get_special_folders, 25 | user_home, get_program_path) 26 | from dirspec.tests import BaseTestCase 27 | from testtools.testcase import skip 28 | 29 | 30 | class UtilsTestCase(BaseTestCase): 31 | """Test for the multiplatform directory utilities.""" 32 | 33 | def test_user_home_is_utf8_bytes(self): 34 | """The returned path is bytes.""" 35 | actual = user_home 36 | self.assert_utf8_bytes(actual) 37 | 38 | 39 | class FakeShellConModule(object): 40 | """Override CSIDL_ constants.""" 41 | 42 | CSIDL_PROFILE = 0 43 | CSIDL_LOCAL_APPDATA = 1 44 | CSIDL_COMMON_APPDATA = 2 45 | 46 | 47 | class FakeShellModule(object): 48 | 49 | """Fake Shell Module.""" 50 | 51 | def __init__(self): 52 | """Set the proper mapping between CSIDL_ consts.""" 53 | self.values = { 54 | 0: 'c:\\path\\to\\users\\home', 55 | 1: 'c:\\path\\to\\users\\home\\appData\\local', 56 | 2: 'c:\\programData', 57 | } 58 | 59 | # pylint: disable=C0103 60 | def SHGetFolderPath(self, dummy0, shellconValue, dummy2, dummy3): 61 | """Override SHGetFolderPath functionality.""" 62 | return self.values[shellconValue] 63 | # pylint: enable=C0103 64 | 65 | 66 | class TestBaseDirectoryWindows(BaseTestCase): 67 | """Tests for the BaseDirectory module.""" 68 | 69 | def test_get_special_folders(self): 70 | """Make sure we can import the platform module.""" 71 | if sys.platform != 'win32': 72 | self.skipTest('Win32 is required for this test.') 73 | 74 | import win32com.shell 75 | shell_module = FakeShellModule() 76 | self.patch(win32com.shell, "shell", shell_module) 77 | self.patch(win32com.shell, "shellcon", FakeShellConModule()) 78 | special_folders = get_special_folders() 79 | self.assertTrue('Personal' in special_folders) 80 | self.assertTrue('Local AppData' in special_folders) 81 | self.assertTrue('AppData' in special_folders) 82 | self.assertTrue('Common AppData' in special_folders) 83 | 84 | self.assertEqual( 85 | special_folders['Personal'], 86 | shell_module.values[FakeShellConModule.CSIDL_PROFILE]) 87 | self.assertEqual( 88 | special_folders['Local AppData'], 89 | shell_module.values[FakeShellConModule.CSIDL_LOCAL_APPDATA]) 90 | self.assertTrue( 91 | special_folders['Local AppData'].startswith( 92 | special_folders['AppData'])) 93 | self.assertEqual( 94 | special_folders['Common AppData'], 95 | shell_module.values[FakeShellConModule.CSIDL_COMMON_APPDATA]) 96 | 97 | for val in special_folders.itervalues(): 98 | self.assertIsInstance(val, str) 99 | val.encode('utf-8') 100 | 101 | def test_get_data_dirs(self): 102 | """Check thet get_data_dirs uses pathsep correctly.""" 103 | bad_sep = str(filter(lambda x: x not in os.pathsep, ":;")) 104 | dir_list = ["A", "B", bad_sep, "C"] 105 | self.tweak_env('XDG_DATA_DIRS', os.pathsep.join(dir_list)) 106 | dirs = basedir.get_xdg_data_dirs()[1:] 107 | self.assertEqual(dirs, [x.encode('utf-8') for x in dir_list]) 108 | 109 | def test_get_config_dirs(self): 110 | """Check thet get_data_dirs uses pathsep correctly.""" 111 | bad_sep = str(filter(lambda x: x not in os.pathsep, ":;")) 112 | dir_list = ["A", "B", bad_sep, "C"] 113 | self.tweak_env('XDG_CONFIG_DIRS', os.pathsep.join(dir_list)) 114 | dirs = basedir.get_xdg_config_dirs()[1:] 115 | self.assertEqual(dirs, [x.encode('utf-8') for x in dir_list]) 116 | 117 | def unset_fake_environ(self, key): 118 | """Unset (and restore) a fake environ variable.""" 119 | if key in os.environ: 120 | current_value = os.environ[key] 121 | self.addCleanup(os.environ.__setitem__, key, current_value) 122 | del(os.environ[key]) 123 | 124 | @skip('UnicodeEncodeError: bug #907053') 125 | def test_get_env_path_var(self): 126 | """Test that get_env_path transforms an env var.""" 127 | fake_path = 'C:\\Users\\Ñandú' 128 | fake_env_var = 'FAKE_ENV_VAR' 129 | 130 | mbcs_path = fake_path.encode(sys.getfilesystemencoding()) 131 | 132 | self.tweak_env(fake_env_var, str(mbcs_path)) 133 | self.assertEqual(get_env_path(fake_env_var, "unexpected"), fake_path) 134 | 135 | @skip('UnicodeEncodeError: bug #907053') 136 | def test_get_env_path_no_var(self): 137 | """Test that get_env_path returns the default when env var not set.""" 138 | fake_path = "C:\\Users\\Ñandú" 139 | fake_env_var = "fake_env_var" 140 | default = fake_path.encode(sys.getfilesystemencoding()) 141 | 142 | self.unset_fake_environ(fake_env_var) 143 | self.assertEqual(get_env_path(fake_env_var, default), default) 144 | 145 | 146 | class ProgramPathBaseTestCase(BaseTestCase): 147 | """Base class for testing the executable finder.""" 148 | 149 | def setUp(self): 150 | """Set up fake modules.""" 151 | super(ProgramPathBaseTestCase, self).setUp() 152 | self.patch(os.path, "exists", lambda x: True) 153 | 154 | 155 | class UnfrozenSrcTestCase(ProgramPathBaseTestCase): 156 | """Test non-linux path discovery.""" 157 | 158 | def setUp(self): 159 | super(UnfrozenSrcTestCase, self).setUp() 160 | self.patch(sys, "platform", "darwin") 161 | 162 | def test_unfrozen_dev_toplevel(self): 163 | """Not frozen, return path to bin dir.""" 164 | path = get_program_path("foo", fallback_dirs=['/path/to/bin']) 165 | self.assertEquals(path, os.path.join("/path/to/bin", "foo")) 166 | 167 | def test_unfrozen_dev_toplevel_raises_nopath(self): 168 | """Not frozen, raise OSError when the path doesn't exist.""" 169 | self.patch(os.path, "exists", lambda x: False) 170 | self.assertRaises(OSError, get_program_path, "foo") 171 | 172 | 173 | class DarwinPkgdTestCase(ProgramPathBaseTestCase): 174 | """Test cmdline for running packaged on darwin.""" 175 | 176 | def setUp(self): 177 | """SetUp to mimic frozen darwin.""" 178 | super(DarwinPkgdTestCase, self).setUp() 179 | self.patch(sys, "platform", "darwin") 180 | sys.frozen = True 181 | 182 | self.darwin_app_names = {"foo": "Foo.app"} 183 | 184 | def tearDown(self): 185 | """tearDown, Remove frozen attr""" 186 | del sys.frozen 187 | super(DarwinPkgdTestCase, self).tearDown() 188 | 189 | def test_darwin_pkgd(self): 190 | """Return sub-app path on darwin when frozen.""" 191 | path = get_program_path("foo", app_names=self.darwin_app_names) 192 | expectedpath = "%s%s" % ( 193 | dirutils.__file__, 194 | os.path.sep + os.path.join('Contents', 'Resources', 'Foo.app', 195 | 'Contents', 'MacOS', 'foo')) 196 | self.assertEquals(path, expectedpath) 197 | 198 | def test_darwin_pkgd_raises_on_no_appnames(self): 199 | """Raises TypeError when no app_names dict is in the kwargs.""" 200 | self.assertRaises(TypeError, get_program_path, "foo") 201 | 202 | def test_darwin_pkgd_raises_nopath(self): 203 | """Frozen, raise OSError when the path doesn't exist.""" 204 | self.patch(os.path, "exists", lambda x: False) 205 | self.assertRaises(OSError, get_program_path, "foo", 206 | app_names=self.darwin_app_names) 207 | 208 | 209 | class Win32PkgdTestCase(ProgramPathBaseTestCase): 210 | """Test cmdline for running packaged on windows.""" 211 | 212 | def setUp(self): 213 | """SetUp to mimic frozen windows.""" 214 | super(Win32PkgdTestCase, self).setUp() 215 | self.patch(sys, "platform", "win32") 216 | sys.frozen = True 217 | 218 | def tearDown(self): 219 | """tearDown, Remove frozen attr""" 220 | del sys.frozen 221 | super(Win32PkgdTestCase, self).tearDown() 222 | 223 | def test_windows_pkgd(self): 224 | """Return sub-app path on windows when frozen.""" 225 | 226 | self.patch(sys, "executable", os.path.join("C:\\path", "to", 227 | "current.exe")) 228 | # patch abspath to let us run this tests on non-windows: 229 | self.patch(os.path, "abspath", lambda x: x) 230 | path = get_program_path("foo", None) 231 | expectedpath = os.path.join("C:\\path", "to", "foo.exe") 232 | self.assertEquals(path, expectedpath) 233 | 234 | def test_windows_pkgd_raises_nopath(self): 235 | """Frozen, raise OSError when the path doesn't exist.""" 236 | self.patch(os.path, "exists", lambda x: False) 237 | self.assertRaises(OSError, get_program_path, "foo") 238 | 239 | 240 | class PosixTestCase(ProgramPathBaseTestCase): 241 | """Test cmdline for running on linux.""" 242 | 243 | def setUp(self): 244 | """SetUp to mimic linux2.""" 245 | super(PosixTestCase, self).setUp() 246 | self.patch(sys, "platform", "linux2") 247 | 248 | def test_linux_src_relative_path_exists(self): 249 | """linux, return source relative path if it exists.""" 250 | path = get_program_path("foo", fallback_dirs=['/path/to/bin']) 251 | expectedpath = os.path.join("/path/to/bin", "foo") 252 | self.assertEquals(path, expectedpath) 253 | 254 | def test_linux_no_src_relative_path(self): 255 | """raise if no src rel path.""" 256 | self.patch(os.path, "exists", lambda x: False) 257 | self.assertRaises(OSError, get_program_path, "foo") 258 | -------------------------------------------------------------------------------- /dirspec/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2011-2012 Canonical Ltd. 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Lesser General Public License version 3 7 | # as published by the Free Software Foundation. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Lesser General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Lesser General Public License 15 | # along with this program. If not, see . 16 | """Utilities for multiplatform support of XDG directory handling.""" 17 | 18 | from __future__ import unicode_literals, print_function 19 | 20 | import errno 21 | import os 22 | import sys 23 | 24 | __all__ = ['user_home', 25 | 'default_cache_home', 26 | 'default_config_home', 27 | 'default_config_path', 28 | 'default_data_home', 29 | 'default_data_path', 30 | 'get_env_path', 31 | 'get_program_path', 32 | 'unicode_path', 33 | ] 34 | 35 | 36 | def _get_exe_path_frozen_win32(exe_name): 37 | """Get path to the helper .exe on packaged windows.""" 38 | # all the .exes are in the same place on windows: 39 | cur_exec_path = os.path.abspath(sys.executable) 40 | exe_dir = os.path.dirname(cur_exec_path) 41 | return os.path.join(exe_dir, exe_name + ".exe") 42 | 43 | 44 | def _get_exe_path_frozen_darwin(exe_name, app_names): 45 | """Get path to the sub-app executable on packaged darwin.""" 46 | 47 | sub_app_name = app_names[exe_name] 48 | main_app_dir = "".join(__file__.partition(".app")[:-1]) 49 | main_app_resources_dir = os.path.join(main_app_dir, 50 | "Contents", 51 | "Resources") 52 | exe_bin = os.path.join(main_app_resources_dir, 53 | sub_app_name, 54 | "Contents", "MacOS", 55 | exe_name) 56 | return exe_bin 57 | 58 | 59 | def get_program_path(program_name, *args, **kwargs): 60 | """Given a program name, returns the path to run that program. 61 | 62 | Raises OSError if the program is not found. 63 | 64 | :param program_name: The name of the program to find. For darwin and win32 65 | platforms, the behavior is changed slightly, when sys.frozen is set, 66 | to look in the packaged program locations for the program. 67 | :param search_dirs: A list of directories to look for the program in. This 68 | is only available as a keyword argument. 69 | :param app_names: A dict of program names mapped to sub-app names. Used 70 | for discovering paths in embedded .app bundles on the darwin platform. 71 | This is only available as a keyword argument. 72 | :return: The path to the discovered program. 73 | """ 74 | search_dirs = kwargs.get('fallback_dirs', None) 75 | app_names = kwargs.get('app_names', None) 76 | 77 | if getattr(sys, "frozen", None) is not None: 78 | if sys.platform == 'win32': 79 | program_path = _get_exe_path_frozen_win32(program_name) 80 | elif sys.platform == 'darwin': 81 | program_path = _get_exe_path_frozen_darwin(program_name, 82 | app_names) 83 | else: 84 | raise Exception("Unsupported platform for frozen execution: %r" % 85 | sys.platform) 86 | else: 87 | if search_dirs is not None: 88 | for dirname in search_dirs: 89 | program_path = os.path.join(dirname, program_name) 90 | if os.path.exists(program_path): 91 | return program_path 92 | else: 93 | # Check in normal system $PATH, if no fallback dirs specified 94 | from distutils.spawn import find_executable 95 | program_path = find_executable(program_name) 96 | 97 | if program_path is None or not os.path.exists(program_path): 98 | raise OSError(errno.ENOENT, 99 | "Could not find executable %r" % program_name) 100 | 101 | return program_path 102 | 103 | 104 | def get_env_path(key, default): 105 | """Get a UTF-8 encoded path from an environment variable.""" 106 | if key in os.environ: 107 | # on windows, environment variables are mbcs bytes 108 | # so we must turn them into utf-8 Syncdaemon paths 109 | try: 110 | path = os.environb.get(key.encode('utf-8')) 111 | except AttributeError: 112 | path = os.environ[key] 113 | return path.decode(sys.getfilesystemencoding()).encode('utf-8') 114 | else: 115 | if not isinstance(default, bytes): 116 | return default.encode('utf-8') 117 | return default 118 | 119 | 120 | def unicode_path(utf8path): 121 | """Turn an utf8 path into a unicode path.""" 122 | if isinstance(utf8path, bytes): 123 | return utf8path.decode("utf-8") 124 | return utf8path 125 | 126 | 127 | def get_special_folders(): 128 | """ Routine to grab all the Windows Special Folders locations. 129 | 130 | If successful, returns dictionary 131 | of shell folder locations indexed on Windows keyword for each; 132 | otherwise, returns an empty dictionary. 133 | """ 134 | # pylint: disable=W0621, F0401, E0611 135 | special_folders = {} 136 | 137 | if sys.platform == 'win32': 138 | from win32com.shell import shell, shellcon 139 | # CSIDL_LOCAL_APPDATA = C:\Users\\AppData\Local 140 | # CSIDL_PROFILE = C:\Users\ 141 | # CSIDL_COMMON_APPDATA = C:\ProgramData 142 | # More information on these constants at 143 | # http://msdn.microsoft.com/en-us/library/bb762494 144 | 145 | # per http://msdn.microsoft.com/en-us/library/windows/desktop/bb762181, 146 | # SHGetFolderPath is deprecated, replaced by SHGetKnownFolderPath 147 | # (http://msdn.microsoft.com/en-us/library/windows/desktop/bb762188) 148 | get_path = lambda name: shell.SHGetFolderPath( 149 | 0, getattr(shellcon, name), None, 0).encode('utf8') 150 | special_folders['Personal'] = get_path("CSIDL_PROFILE") 151 | special_folders['Local AppData'] = get_path("CSIDL_LOCAL_APPDATA") 152 | special_folders['AppData'] = os.path.dirname( 153 | special_folders['Local AppData']) 154 | special_folders['Common AppData'] = get_path("CSIDL_COMMON_APPDATA") 155 | 156 | return special_folders 157 | 158 | 159 | # pylint: disable=C0103 160 | if sys.platform == 'win32': 161 | special_folders = get_special_folders() 162 | user_home = special_folders['Personal'] 163 | default_config_path = special_folders['Common AppData'] 164 | default_config_home = special_folders['Local AppData'] 165 | default_data_path = os.path.join(default_config_path, b'xdg') 166 | default_data_home = os.path.join(default_config_home, b'xdg') 167 | default_cache_home = os.path.join(default_data_home, b'cache') 168 | elif sys.platform == 'darwin': 169 | user_home = os.path.expanduser(b'~') 170 | default_cache_home = os.path.join(user_home, b'Library', b'Caches') 171 | default_config_path = b'/Library/Preferences:/etc/xdg' 172 | default_config_home = os.path.join(user_home, b'Library', b'Preferences') 173 | default_data_path = b':'.join([b'/Library/Application Support', 174 | b'/usr/local/share', 175 | b'/usr/share']) 176 | default_data_home = os.path.join(user_home, b'Library', 177 | b'Application Support') 178 | else: 179 | user_home = os.path.expanduser(b'~') 180 | default_cache_home = os.path.join(user_home, 181 | b'.cache') 182 | default_config_path = b'/etc/xdg' 183 | default_config_home = os.path.join(user_home, 184 | b'.config') 185 | default_data_path = b'/usr/local/share:/usr/share' 186 | default_data_home = os.path.join(user_home, 187 | b'.local', b'share') 188 | -------------------------------------------------------------------------------- /ez_setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Bootstrap setuptools installation 3 | 4 | To use setuptools in your package's setup.py, include this 5 | file in the same directory and add this to the top of your setup.py:: 6 | 7 | from ez_setup import use_setuptools 8 | use_setuptools() 9 | 10 | To require a specific version of setuptools, set a download 11 | mirror, or use an alternate download directory, simply supply 12 | the appropriate options to ``use_setuptools()``. 13 | 14 | This file can also be run as a script to install or upgrade setuptools. 15 | """ 16 | import os 17 | import shutil 18 | import sys 19 | import tempfile 20 | import zipfile 21 | import optparse 22 | import subprocess 23 | import platform 24 | import textwrap 25 | import contextlib 26 | 27 | from distutils import log 28 | 29 | try: 30 | from urllib.request import urlopen 31 | except ImportError: 32 | from urllib2 import urlopen 33 | 34 | try: 35 | from site import USER_SITE 36 | except ImportError: 37 | USER_SITE = None 38 | 39 | DEFAULT_VERSION = "9.1" 40 | DEFAULT_URL = "https://pypi.python.org/packages/source/s/setuptools/" 41 | 42 | def _python_cmd(*args): 43 | """ 44 | Return True if the command succeeded. 45 | """ 46 | args = (sys.executable,) + args 47 | return subprocess.call(args) == 0 48 | 49 | 50 | def _install(archive_filename, install_args=()): 51 | with archive_context(archive_filename): 52 | # installing 53 | log.warn('Installing Setuptools') 54 | if not _python_cmd('setup.py', 'install', *install_args): 55 | log.warn('Something went wrong during the installation.') 56 | log.warn('See the error message above.') 57 | # exitcode will be 2 58 | return 2 59 | 60 | 61 | def _build_egg(egg, archive_filename, to_dir): 62 | with archive_context(archive_filename): 63 | # building an egg 64 | log.warn('Building a Setuptools egg in %s', to_dir) 65 | _python_cmd('setup.py', '-q', 'bdist_egg', '--dist-dir', to_dir) 66 | # returning the result 67 | log.warn(egg) 68 | if not os.path.exists(egg): 69 | raise IOError('Could not build the egg.') 70 | 71 | 72 | class ContextualZipFile(zipfile.ZipFile): 73 | """ 74 | Supplement ZipFile class to support context manager for Python 2.6 75 | """ 76 | 77 | def __enter__(self): 78 | return self 79 | 80 | def __exit__(self, type, value, traceback): 81 | self.close() 82 | 83 | def __new__(cls, *args, **kwargs): 84 | """ 85 | Construct a ZipFile or ContextualZipFile as appropriate 86 | """ 87 | if hasattr(zipfile.ZipFile, '__exit__'): 88 | return zipfile.ZipFile(*args, **kwargs) 89 | return super(ContextualZipFile, cls).__new__(cls) 90 | 91 | 92 | @contextlib.contextmanager 93 | def archive_context(filename): 94 | # extracting the archive 95 | tmpdir = tempfile.mkdtemp() 96 | log.warn('Extracting in %s', tmpdir) 97 | old_wd = os.getcwd() 98 | try: 99 | os.chdir(tmpdir) 100 | with ContextualZipFile(filename) as archive: 101 | archive.extractall() 102 | 103 | # going in the directory 104 | subdir = os.path.join(tmpdir, os.listdir(tmpdir)[0]) 105 | os.chdir(subdir) 106 | log.warn('Now working in %s', subdir) 107 | yield 108 | 109 | finally: 110 | os.chdir(old_wd) 111 | shutil.rmtree(tmpdir) 112 | 113 | 114 | def _do_download(version, download_base, to_dir, download_delay): 115 | egg = os.path.join(to_dir, 'setuptools-%s-py%d.%d.egg' 116 | % (version, sys.version_info[0], sys.version_info[1])) 117 | if not os.path.exists(egg): 118 | archive = download_setuptools(version, download_base, 119 | to_dir, download_delay) 120 | _build_egg(egg, archive, to_dir) 121 | sys.path.insert(0, egg) 122 | 123 | # Remove previously-imported pkg_resources if present (see 124 | # https://bitbucket.org/pypa/setuptools/pull-request/7/ for details). 125 | if 'pkg_resources' in sys.modules: 126 | del sys.modules['pkg_resources'] 127 | 128 | import setuptools 129 | setuptools.bootstrap_install_from = egg 130 | 131 | 132 | def use_setuptools(version=DEFAULT_VERSION, download_base=DEFAULT_URL, 133 | to_dir=os.curdir, download_delay=15): 134 | to_dir = os.path.abspath(to_dir) 135 | rep_modules = 'pkg_resources', 'setuptools' 136 | imported = set(sys.modules).intersection(rep_modules) 137 | try: 138 | import pkg_resources 139 | except ImportError: 140 | return _do_download(version, download_base, to_dir, download_delay) 141 | try: 142 | pkg_resources.require("setuptools>=" + version) 143 | return 144 | except pkg_resources.DistributionNotFound: 145 | return _do_download(version, download_base, to_dir, download_delay) 146 | except pkg_resources.VersionConflict as VC_err: 147 | if imported: 148 | msg = textwrap.dedent(""" 149 | The required version of setuptools (>={version}) is not available, 150 | and can't be installed while this script is running. Please 151 | install a more recent version first, using 152 | 'easy_install -U setuptools'. 153 | 154 | (Currently using {VC_err.args[0]!r}) 155 | """).format(VC_err=VC_err, version=version) 156 | sys.stderr.write(msg) 157 | sys.exit(2) 158 | 159 | # otherwise, reload ok 160 | del pkg_resources, sys.modules['pkg_resources'] 161 | return _do_download(version, download_base, to_dir, download_delay) 162 | 163 | def _clean_check(cmd, target): 164 | """ 165 | Run the command to download target. If the command fails, clean up before 166 | re-raising the error. 167 | """ 168 | try: 169 | subprocess.check_call(cmd) 170 | except subprocess.CalledProcessError: 171 | if os.access(target, os.F_OK): 172 | os.unlink(target) 173 | raise 174 | 175 | def download_file_powershell(url, target): 176 | """ 177 | Download the file at url to target using Powershell (which will validate 178 | trust). Raise an exception if the command cannot complete. 179 | """ 180 | target = os.path.abspath(target) 181 | ps_cmd = ( 182 | "[System.Net.WebRequest]::DefaultWebProxy.Credentials = " 183 | "[System.Net.CredentialCache]::DefaultCredentials; " 184 | "(new-object System.Net.WebClient).DownloadFile(%(url)r, %(target)r)" 185 | % vars() 186 | ) 187 | cmd = [ 188 | 'powershell', 189 | '-Command', 190 | ps_cmd, 191 | ] 192 | _clean_check(cmd, target) 193 | 194 | def has_powershell(): 195 | if platform.system() != 'Windows': 196 | return False 197 | cmd = ['powershell', '-Command', 'echo test'] 198 | with open(os.path.devnull, 'wb') as devnull: 199 | try: 200 | subprocess.check_call(cmd, stdout=devnull, stderr=devnull) 201 | except Exception: 202 | return False 203 | return True 204 | 205 | download_file_powershell.viable = has_powershell 206 | 207 | def download_file_curl(url, target): 208 | cmd = ['curl', url, '--silent', '--output', target] 209 | _clean_check(cmd, target) 210 | 211 | def has_curl(): 212 | cmd = ['curl', '--version'] 213 | with open(os.path.devnull, 'wb') as devnull: 214 | try: 215 | subprocess.check_call(cmd, stdout=devnull, stderr=devnull) 216 | except Exception: 217 | return False 218 | return True 219 | 220 | download_file_curl.viable = has_curl 221 | 222 | def download_file_wget(url, target): 223 | cmd = ['wget', url, '--quiet', '--output-document', target] 224 | _clean_check(cmd, target) 225 | 226 | def has_wget(): 227 | cmd = ['wget', '--version'] 228 | with open(os.path.devnull, 'wb') as devnull: 229 | try: 230 | subprocess.check_call(cmd, stdout=devnull, stderr=devnull) 231 | except Exception: 232 | return False 233 | return True 234 | 235 | download_file_wget.viable = has_wget 236 | 237 | def download_file_insecure(url, target): 238 | """ 239 | Use Python to download the file, even though it cannot authenticate the 240 | connection. 241 | """ 242 | src = urlopen(url) 243 | try: 244 | # Read all the data in one block. 245 | data = src.read() 246 | finally: 247 | src.close() 248 | 249 | # Write all the data in one block to avoid creating a partial file. 250 | with open(target, "wb") as dst: 251 | dst.write(data) 252 | 253 | download_file_insecure.viable = lambda: True 254 | 255 | def get_best_downloader(): 256 | downloaders = ( 257 | download_file_powershell, 258 | download_file_curl, 259 | download_file_wget, 260 | download_file_insecure, 261 | ) 262 | viable_downloaders = (dl for dl in downloaders if dl.viable()) 263 | return next(viable_downloaders, None) 264 | 265 | def download_setuptools(version=DEFAULT_VERSION, download_base=DEFAULT_URL, 266 | to_dir=os.curdir, delay=15, downloader_factory=get_best_downloader): 267 | """ 268 | Download setuptools from a specified location and return its filename 269 | 270 | `version` should be a valid setuptools version number that is available 271 | as an sdist for download under the `download_base` URL (which should end 272 | with a '/'). `to_dir` is the directory where the egg will be downloaded. 273 | `delay` is the number of seconds to pause before an actual download 274 | attempt. 275 | 276 | ``downloader_factory`` should be a function taking no arguments and 277 | returning a function for downloading a URL to a target. 278 | """ 279 | # making sure we use the absolute path 280 | to_dir = os.path.abspath(to_dir) 281 | zip_name = "setuptools-%s.zip" % version 282 | url = download_base + zip_name 283 | saveto = os.path.join(to_dir, zip_name) 284 | if not os.path.exists(saveto): # Avoid repeated downloads 285 | log.warn("Downloading %s", url) 286 | downloader = downloader_factory() 287 | downloader(url, saveto) 288 | return os.path.realpath(saveto) 289 | 290 | def _build_install_args(options): 291 | """ 292 | Build the arguments to 'python setup.py install' on the setuptools package 293 | """ 294 | return ['--user'] if options.user_install else [] 295 | 296 | def _parse_args(): 297 | """ 298 | Parse the command line for options 299 | """ 300 | parser = optparse.OptionParser() 301 | parser.add_option( 302 | '--user', dest='user_install', action='store_true', default=False, 303 | help='install in user site package (requires Python 2.6 or later)') 304 | parser.add_option( 305 | '--download-base', dest='download_base', metavar="URL", 306 | default=DEFAULT_URL, 307 | help='alternative URL from where to download the setuptools package') 308 | parser.add_option( 309 | '--insecure', dest='downloader_factory', action='store_const', 310 | const=lambda: download_file_insecure, default=get_best_downloader, 311 | help='Use internal, non-validating downloader' 312 | ) 313 | parser.add_option( 314 | '--version', help="Specify which version to download", 315 | default=DEFAULT_VERSION, 316 | ) 317 | options, args = parser.parse_args() 318 | # positional arguments are ignored 319 | return options 320 | 321 | def main(): 322 | """Install or upgrade setuptools and EasyInstall""" 323 | options = _parse_args() 324 | archive = download_setuptools( 325 | version=options.version, 326 | download_base=options.download_base, 327 | downloader_factory=options.downloader_factory, 328 | ) 329 | return _install(archive, _build_install_args(options)) 330 | 331 | if __name__ == '__main__': 332 | sys.exit(main()) 333 | -------------------------------------------------------------------------------- /geofrontcli/__init__.py: -------------------------------------------------------------------------------- 1 | """:mod:`geofrontcli` --- Geofront CLI client 2 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 3 | 4 | """ 5 | -------------------------------------------------------------------------------- /geofrontcli/cli.py: -------------------------------------------------------------------------------- 1 | """:mod:`geofrontcli.cli` --- CLI main 2 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 3 | 4 | """ 5 | from __future__ import print_function 6 | 7 | import argparse 8 | import logging 9 | import os 10 | import os.path 11 | import subprocess 12 | import sys 13 | import webbrowser 14 | 15 | from dirspec.basedir import load_config_paths, save_config_path 16 | from iterfzf import iterfzf 17 | from logging_spinner import SpinnerHandler, UserWaitingFilter 18 | from six.moves import input 19 | 20 | from .client import (REMOTE_PATTERN, Client, ExpiredTokenIdError, 21 | NoTokenIdError, ProtocolVersionError, RemoteError, 22 | TokenIdError, UnfinishedAuthenticationError) 23 | from .key import PublicKey 24 | from .version import VERSION 25 | 26 | 27 | CONFIG_RESOURCE = 'geofront-cli' 28 | SERVER_CONFIG_FILENAME = 'server' 29 | 30 | WHICH_CMD = 'where' if sys.platform == 'win32' else 'which' 31 | 32 | SSH_PROGRAM = None 33 | try: 34 | SSH_PROGRAM = subprocess.check_output([WHICH_CMD, 'ssh']).strip() or None 35 | except subprocess.CalledProcessError: 36 | pass 37 | 38 | SCP_PROGRAM = None 39 | try: 40 | SCP_PROGRAM = subprocess.check_output([WHICH_CMD, 'scp']).strip() or None 41 | except subprocess.CalledProcessError: 42 | pass 43 | 44 | 45 | parser = argparse.ArgumentParser(description='Geofront client utility') 46 | parser.add_argument( 47 | '-S', '--ssh', 48 | default=SSH_PROGRAM, 49 | required=not SSH_PROGRAM, 50 | help='ssh client to use' + (' [%(default)s]' if SSH_PROGRAM else '') 51 | ) 52 | parser.add_argument('-d', '--debug', action='store_true', help='debug mode') 53 | parser.add_argument('-v', '--version', action='version', 54 | version='%(prog)s ' + VERSION) 55 | subparsers = parser.add_subparsers() 56 | 57 | 58 | def get_server_url(): 59 | for path in load_config_paths(CONFIG_RESOURCE): 60 | path = os.path.join(path.decode(), SERVER_CONFIG_FILENAME) 61 | if os.path.isfile(path): 62 | with open(path) as f: 63 | return f.read().strip() 64 | parser.exit('Geofront server URL is not configured yet.\n' 65 | 'Try `{0} start` command.'.format(parser.prog)) 66 | 67 | 68 | def get_client(): 69 | server_url = get_server_url() 70 | return Client(server_url) 71 | 72 | 73 | def subparser(function): 74 | """Register a subparser function.""" 75 | p = subparsers.add_parser(function.__name__, description=function.__doc__) 76 | p.set_defaults(function=function) 77 | p.call = function 78 | return p 79 | 80 | 81 | @subparser 82 | def start(args): 83 | """Set up the Geofront server URL.""" 84 | for path in load_config_paths(CONFIG_RESOURCE): 85 | path = os.path.join(path.decode(), SERVER_CONFIG_FILENAME) 86 | if os.path.isfile(path): 87 | message = 'Geofront server URL is already configured: ' + path 88 | if args.force: 89 | print(message + '; overwriting...', file=sys.stderr) 90 | else: 91 | parser.exit(message) 92 | while True: 93 | server_url = input('Geofront server URL: ') 94 | if not server_url.startswith(('https://', 'http://')): 95 | print(server_url, 'is not a valid url.') 96 | continue 97 | elif not server_url.startswith('https://'): 98 | cont = input('It is not a secure URL. ' 99 | 'https:// is preferred over http://. ' 100 | 'Continue (y/N)? ') 101 | if cont.strip().lower() != 'y': 102 | continue 103 | break 104 | server_config_filename = os.path.join( 105 | save_config_path(CONFIG_RESOURCE).decode(), 106 | SERVER_CONFIG_FILENAME 107 | ) 108 | with open(server_config_filename, 'w') as f: 109 | print(server_url, file=f) 110 | authenticate.call(args) 111 | 112 | 113 | start.add_argument('-f', '--force', 114 | action='store_true', 115 | help='overwrite the server url configuration') 116 | 117 | 118 | @subparser 119 | def authenticate(args): 120 | """Authenticate to Geofront server.""" 121 | client = get_client() 122 | while True: 123 | with client.authenticate() as url: 124 | if args.open_browser: 125 | print('Continue to authenticate in your web browser...') 126 | webbrowser.open(url) 127 | else: 128 | print('Continue to authenticate in your web browser:') 129 | print(url) 130 | input('Press return to continue') 131 | try: 132 | client.identity 133 | except UnfinishedAuthenticationError as e: 134 | print(str(e)) 135 | else: 136 | break 137 | home = os.path.expanduser('~') 138 | ssh_dir = os.path.join(home, '.ssh') 139 | if os.path.isdir(ssh_dir): 140 | for name in 'id_rsa.pub', 'id_dsa.pub': 141 | pubkey_path = os.path.join(ssh_dir, name) 142 | if os.path.isfile(pubkey_path): 143 | with open(pubkey_path) as f: 144 | public_key = PublicKey.parse_line(f.read()) 145 | break 146 | else: 147 | public_key = None 148 | if public_key and public_key.fingerprint not in client.public_keys: 149 | print('You have a public key ({0}), and it is not registered ' 150 | 'to the Geofront server ({1}).'.format(pubkey_path, 151 | client.server_url)) 152 | while True: 153 | register = input('Would you register the public key to ' 154 | 'the Geofront server (Y/n)? ').strip() 155 | if register.lower() in ('', 'y', 'n'): 156 | break 157 | print('{0!r} is an invalid answer.'.format(register)) 158 | if register.lower() != 'n': 159 | try: 160 | client.public_keys[public_key.fingerprint] = public_key 161 | except ValueError as e: 162 | print(e, file=sys.stderr) 163 | if args.debug: 164 | raise 165 | 166 | 167 | @subparser 168 | def keys(args): 169 | """List registered public keys.""" 170 | client = get_client() 171 | for fingerprint, key in client.public_keys.items(): 172 | if args.fingerprint: 173 | print(fingerprint) 174 | else: 175 | print(key) 176 | 177 | 178 | keys.add_argument( 179 | '-v', '--verbose', 180 | dest='fingerprint', 181 | action='store_false', 182 | help='print public keys with OpenSSH authorized_keys format instead of ' 183 | 'fingerprints' 184 | ) 185 | 186 | 187 | @subparser 188 | def masterkey(args): 189 | """Show the current master key.""" 190 | client = get_client() 191 | master_key = client.master_key 192 | if args.fingerprint: 193 | print(master_key.fingerprint) 194 | else: 195 | print(master_key) 196 | 197 | 198 | masterkey.add_argument( 199 | '-v', '--verbose', 200 | dest='fingerprint', 201 | action='store_false', 202 | help='print the master key with OpenSSH authorized_keys format instead of ' 203 | 'its fingerprint' 204 | ) 205 | 206 | 207 | def align_remote_list(remotes): 208 | maxlength = max(map(len, remotes)) if remotes else 0 209 | for alias, remote in sorted(remotes.items()): 210 | if remote.endswith(':22'): 211 | remote = remote[:-3] 212 | yield '{0:{1}} {2}'.format(alias, maxlength, remote) 213 | 214 | 215 | @subparser 216 | def remotes(args): 217 | """List available remotes.""" 218 | client = get_client() 219 | remotes = client.remotes 220 | if args.alias: 221 | for alias in sorted(remotes): 222 | print(alias) 223 | else: 224 | for line in align_remote_list(remotes): 225 | print(line) 226 | 227 | 228 | remotes.add_argument( 229 | '-v', '--verbose', 230 | dest='alias', 231 | action='store_false', 232 | help='print remote aliases with their actual addresses, not only aliases' 233 | ) 234 | 235 | 236 | @subparser 237 | def authorize(args, alias=None): 238 | """Temporarily authorize you to access the given remote. 239 | A made authorization keeps alive in a minute, and then will be expired. 240 | 241 | """ 242 | client = get_client() 243 | while True: 244 | try: 245 | remote = client.authorize(alias or args.remote) 246 | except RemoteError as e: 247 | print(e, file=sys.stderr) 248 | if args.debug: 249 | raise 250 | except TokenIdError: 251 | print('Authentication required.', file=sys.stderr) 252 | authenticate.call(args) 253 | else: 254 | break 255 | return remote 256 | 257 | 258 | authorize.add_argument( 259 | 'remote', 260 | help='the remote alias to authorize you to access' 261 | ) 262 | 263 | 264 | def get_ssh_options(remote): 265 | """Translate the given ``remote`` to a corresponding :program:`ssh` 266 | options. For example, it returns the following list for ``'user@host'``:: 267 | 268 | ['-l', 'user', 'host'] 269 | 270 | The remote can contain the port number or omit the user login as well 271 | e.g. ``'host:22'``:: 272 | 273 | ['-p', '22', 'host'] 274 | 275 | """ 276 | remote_match = REMOTE_PATTERN.match(remote) 277 | if not remote_match: 278 | raise ValueError('invalid remote format: ' + str(remote)) 279 | options = [] 280 | user = remote_match.group('user') 281 | if user: 282 | options.extend(['-l', user]) 283 | port = remote_match.group('port') 284 | if port: 285 | options.extend(['-p', port]) 286 | options.append(remote_match.group('host')) 287 | return options 288 | 289 | 290 | @subparser 291 | def colonize(args): 292 | """Make the given remote to allow the current master key. 293 | It is equivalent to ``geofront-cli masterkey -v > /tmp/master_id_rsa && 294 | ssh-copy-id -i /tmp/master_id_rsa REMOTE``. 295 | 296 | """ 297 | client = get_client() 298 | remote = client.remotes.get(args.remote, args.remote) 299 | try: 300 | options = get_ssh_options(remote) 301 | except ValueError as e: 302 | colonize.error(str(e)) 303 | cmd = [args.ssh] 304 | if args.identity_file: 305 | cmd.extend(['-i', args.identity_file]) 306 | cmd.extend(options) 307 | cmd.extend([ 308 | 'mkdir', '~/.ssh', '&>', '/dev/null', '||', 'true', ';', 309 | 'echo', repr(str(client.master_key)), 310 | '>>', '~/.ssh/authorized_keys' 311 | ]) 312 | subprocess.call(cmd) 313 | 314 | 315 | colonize.add_argument( 316 | '-i', 317 | dest='identity_file', 318 | help='identity file to use. it will be forwarded to the same option ' 319 | 'of the ssh program if used' 320 | ) 321 | colonize.add_argument('remote', help='the remote alias to colonize') 322 | 323 | 324 | @subparser 325 | def ssh(args, alias=None): 326 | """SSH to the remote through Geofront's temporary authorization.""" 327 | remote = authorize.call(args, alias=alias) 328 | try: 329 | options = get_ssh_options(remote) 330 | except ValueError as e: 331 | ssh.error(str(e)) 332 | if args.jump_host: 333 | options.extend(['-o', 'ProxyJump=={}'.format(args.jump_host)]) 334 | subprocess.call([args.ssh] + options) 335 | 336 | 337 | ssh.add_argument('remote', help='the remote alias to ssh') 338 | 339 | 340 | def parse_scp_path(path, args): 341 | """Parse remote:path format.""" 342 | if ':' not in path: 343 | return None, path 344 | alias, path = path.split(':', 1) 345 | remote = authorize.call(args, alias=alias) 346 | return remote, path 347 | 348 | 349 | @subparser 350 | def scp(args): 351 | options = [] 352 | src_remote, src_path = parse_scp_path(args.source, args) 353 | dst_remote, dst_path = parse_scp_path(args.destination, args) 354 | if src_remote and dst_remote: 355 | scp.error('source and destination cannot be both ' 356 | 'remote paths at a time') 357 | elif not (src_remote or dst_remote): 358 | scp.error('one of source and destination has to be a remote path') 359 | if args.ssh: 360 | options.extend(['-S', args.ssh]) 361 | if args.recursive: 362 | options.append('-r') 363 | if args.jump_host: 364 | options.extend(['-o', 'ProxyJump=={}'.format(args.jump_host)]) 365 | remote = src_remote or dst_remote 366 | remote_match = REMOTE_PATTERN.match(remote) 367 | if not remote_match: 368 | raise ValueError('invalid remote format: ' + str(remote)) 369 | port = remote_match.group('port') 370 | if port: 371 | options.extend(['-P', port]) 372 | host = remote_match.group('host') 373 | user = remote_match.group('user') 374 | if user: 375 | host = user + '@' + host 376 | if src_remote: 377 | options.append(host + ':' + src_path) 378 | else: 379 | options.append(src_path) 380 | if dst_remote: 381 | options.append(host + ':' + dst_path) 382 | else: 383 | options.append(dst_path) 384 | subprocess.call([args.scp] + options) 385 | 386 | 387 | scp.add_argument( 388 | '--scp', 389 | default=SCP_PROGRAM, 390 | required=not SCP_PROGRAM, 391 | help='scp client to use' + (' [%(default)s]' if SCP_PROGRAM else '') 392 | ) 393 | scp.add_argument( 394 | '-r', '-R', '--recursive', 395 | action='store_true', 396 | help='recursively copy entire directories' 397 | ) 398 | scp.add_argument('source', help='the source path to copy') 399 | scp.add_argument('destination', help='the destination path') 400 | 401 | 402 | @subparser 403 | def go(args): 404 | """Select a remote and SSH to it at once (in interactive way).""" 405 | client = get_client() 406 | remotes = client.remotes 407 | chosen = iterfzf(align_remote_list(remotes)) 408 | if chosen is None: 409 | return 410 | alias = chosen.split()[0] 411 | ssh.call(args, alias=alias) 412 | 413 | 414 | for p in authenticate, authorize, start, ssh, scp, go: 415 | p.add_argument( 416 | '-O', '--no-open-browser', 417 | dest='open_browser', 418 | action='store_false', 419 | help='do not open the authentication web page using browser. ' 420 | 'instead print the url to open' 421 | ) 422 | p.add_argument( 423 | '-J', '--jump-host', 424 | default=None, 425 | help='Proxy jump host to use' 426 | ) 427 | 428 | 429 | def fix_mac_codesign(): 430 | """If the running Python interpreter isn't property signed on macOS 431 | it's unable to get/set password using keyring from Keychain. 432 | 433 | In such case, we need to sign the interpreter first. 434 | 435 | https://github.com/jaraco/keyring/issues/219 436 | 437 | """ 438 | global fix_mac_codesign 439 | logger = logging.getLogger(__name__ + '.fix_mac_codesign') 440 | p = subprocess.Popen(['codesign', '-dvvvvv', sys.executable], 441 | stdout=subprocess.PIPE, stderr=subprocess.PIPE) 442 | stdout, stderr = p.communicate() 443 | 444 | def prepend_lines(c, text): 445 | if not isinstance(text, str): 446 | text = text.decode() 447 | return ''.join(c + l for l in text.splitlines(True)) 448 | logger.debug('codesign -dvvvvv %s:\n%s\n%s', 449 | sys.executable, 450 | prepend_lines('| ', stdout), 451 | prepend_lines('> ', stderr)) 452 | if b'\nSignature=' in stderr: 453 | logger.debug('%s: already signed', sys.executable) 454 | return 455 | logger.info('%s: not signed yet; try signing...', sys.executable) 456 | p = subprocess.Popen(['codesign', '-f', '-s', '-', sys.executable], 457 | stdout=subprocess.PIPE, stderr=subprocess.PIPE) 458 | os.waitpid(p.pid, 0) 459 | logger.debug('%s: signed\n%s\n%s', 460 | sys.executable, 461 | prepend_lines('| ', stdout), 462 | prepend_lines('> ', stderr)) 463 | logger.debug('respawn the equivalent process...') 464 | raise SystemExit(subprocess.call(sys.argv)) 465 | 466 | 467 | def main(args=None): 468 | args = parser.parse_args(args) 469 | log_handler = logging.StreamHandler(sys.stdout) 470 | log_handler.addFilter(UserWaitingFilter()) 471 | spinner_handler = SpinnerHandler(sys.stdout) 472 | local = logging.getLogger('geofrontcli') 473 | if args.debug: 474 | root = logging.getLogger() 475 | root.setLevel(logging.INFO) 476 | root.addHandler(log_handler) 477 | local.setLevel(logging.DEBUG) 478 | else: 479 | local.setLevel(logging.INFO) 480 | local.addHandler(log_handler) 481 | local.addHandler(spinner_handler) 482 | if sys.platform == 'darwin': 483 | fix_mac_codesign() 484 | if getattr(args, 'function', None): 485 | try: 486 | args.function(args) 487 | except NoTokenIdError: 488 | parser.exit('Not authenticated yet.\n' 489 | 'Try `{0} authenticate` command.'.format(parser.prog)) 490 | except ExpiredTokenIdError: 491 | parser.exit('Authentication renewal required.\n' 492 | 'Try `{0} authenticate` command.'.format(parser.prog)) 493 | except ProtocolVersionError as e: 494 | parser.exit('geofront-cli seems incompatible with the server.\n' 495 | 'Try `pip install --upgrade geofront-cli` command.\n' 496 | 'The server version is {0}.'.format(e.server_version)) 497 | else: 498 | parser.print_usage() 499 | 500 | 501 | def main_go(): 502 | parser.prog = 'geofront-cli' 503 | main(['go']) 504 | -------------------------------------------------------------------------------- /geofrontcli/client.py: -------------------------------------------------------------------------------- 1 | """:mod:`geofrontcli.client` --- Client 2 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 3 | 4 | """ 5 | import contextlib 6 | import io 7 | import json 8 | import logging 9 | import re 10 | import sys 11 | import uuid 12 | 13 | from collections.abc import MutableMapping 14 | from keyring import get_password, set_password 15 | from six import string_types 16 | from six.moves.urllib.error import HTTPError 17 | from six.moves.urllib.parse import urljoin 18 | from six.moves.urllib.request import OpenerDirector, Request, build_opener 19 | 20 | from .key import PublicKey 21 | from .ssl import create_urllib_https_handler 22 | from .version import MIN_PROTOCOL_VERSION, MAX_PROTOCOL_VERSION, VERSION 23 | 24 | __all__ = ('REMOTE_PATTERN', 'BufferedResponse', 25 | 'Client', 'ExpiredTokenIdError', 26 | 'MasterKeyError', 'NoTokenIdError', 'ProtocolVersionError', 27 | 'RemoteAliasError', 'RemoteError', 'RemoteStateError', 28 | 'TokenIdError', 'UnfinishedAuthenticationError', 29 | 'parse_mimetype') 30 | 31 | 32 | #: (:class:`re.RegexObject`) The pattern that matches to the remote string 33 | #: look like ``'user@host:port'``. 34 | REMOTE_PATTERN = re.compile(r'^(?:(?P[^@]+)@)?' 35 | r'(?P[^:]+)' 36 | r'(?::(?P\d+))?$') 37 | 38 | 39 | def parse_mimetype(content_type): 40 | """Parse :mailheader:`Content-Type` header and return the actual mimetype 41 | and its options. 42 | 43 | >>> parse_mimetype('text/html; charset=utf-8') 44 | ('text/html', ['charset=utf-8']) 45 | 46 | """ 47 | values = [v.strip() for v in content_type.split(';')] 48 | return values[0], values[1:] 49 | 50 | 51 | class Client(object): 52 | """Client for a configured Geofront server.""" 53 | 54 | #: (:class:`PublicKeyDict`) Public keys registered to Geofront server. 55 | public_keys = None 56 | 57 | def __init__(self, server_url, opener=None): 58 | self.logger = logging.getLogger(__name__ + '.Client') 59 | self.server_url = server_url 60 | if opener is None: 61 | opener = build_opener(create_urllib_https_handler()) 62 | elif not isinstance(opener, OpenerDirector): 63 | raise TypeError('opener must be {0.__module__}.{0.__name__}, not ' 64 | '{1!r}'.format(OpenerDirector, opener)) 65 | self.opener = opener 66 | self.public_keys = PublicKeyDict(self) 67 | 68 | @contextlib.contextmanager 69 | def request(self, method, url, data=None, headers={}): 70 | logger = self.logger.getChild('request') 71 | if isinstance(url, tuple): 72 | url = './{0}/'.format('/'.join(url)) 73 | url = urljoin(self.server_url, url) 74 | h = { 75 | 'User-Agent': 'geofront-cli/{0} (Python-urllib/{1})'.format( 76 | VERSION, sys.version[:3] 77 | ), 78 | 'Accept': 'application/json' 79 | } 80 | h.update(headers) 81 | request = Request(url, method=method, data=data, headers=h) 82 | try: 83 | response = self.opener.open(request) 84 | except HTTPError as e: 85 | logger.exception(e) 86 | response = e 87 | server_version = response.headers.get('X-Geofront-Version') 88 | if server_version: 89 | try: 90 | server_version_info = tuple( 91 | map(int, server_version.strip().split('.')) 92 | ) 93 | except ValueError: 94 | raise ProtocolVersionError( 95 | None, 96 | 'the protocol version number the server sent is not ' 97 | 'a valid format: ' + repr(server_version) 98 | ) 99 | else: 100 | if not (MIN_PROTOCOL_VERSION <= 101 | server_version_info <= 102 | MAX_PROTOCOL_VERSION): 103 | raise ProtocolVersionError( 104 | server_version_info, 105 | 'the server protocol version ({0}) is ' 106 | 'incompatible'.format(server_version) 107 | ) 108 | else: 109 | raise ProtocolVersionError( 110 | None, 111 | 'the server did not send the protocol version ' 112 | '(X-Geofront-Version)' 113 | ) 114 | mimetype, _ = parse_mimetype(response.headers['Content-Type']) 115 | if mimetype == 'application/json' and 400 <= response.code < 500: 116 | read = response.read() 117 | body = json.loads(read.decode('utf-8')) 118 | response.close() 119 | error = isinstance(body, dict) and body.get('error') 120 | if response.code == 404 and error == 'token-not-found' or \ 121 | response.code == 410 and error == 'expired-token': 122 | raise ExpiredTokenIdError('token id seems expired') 123 | elif response.code == 412 and error == 'unfinished-authentication': 124 | raise UnfinishedAuthenticationError(body['message']) 125 | buffered = BufferedResponse(response.code, response.headers, read) 126 | yield buffered 127 | buffered.close() 128 | return 129 | yield response 130 | response.close() 131 | 132 | @property 133 | def token_id(self): 134 | """(:class:`str`) The previously authenticated token id stored 135 | in the system password store (e.g. Keychain of Mac). 136 | 137 | """ 138 | token_id = get_password('geofront-cli', self.server_url) 139 | if token_id: 140 | return token_id 141 | raise NoTokenIdError('no configured token id') 142 | 143 | @token_id.setter 144 | def token_id(self, token_id): 145 | set_password('geofront-cli', self.server_url, token_id) 146 | 147 | @contextlib.contextmanager 148 | def authenticate(self): 149 | """Authenticate and then store the :attr:`token_id`.""" 150 | token_id = uuid.uuid1().hex 151 | with self.request('PUT', ('tokens', token_id)) as response: 152 | assert response.code == 202 153 | result = json.loads(response.read().decode('utf-8')) 154 | yield result['next_url'] 155 | self.token_id = token_id 156 | 157 | @property 158 | def identity(self): 159 | """(:class:`tuple`) A pair of ``(team_type, identifier)``.""" 160 | with self.request('GET', ('tokens', self.token_id)) as r: 161 | assert r.code == 200 162 | mimetype, _ = parse_mimetype(r.headers['Content-Type']) 163 | assert mimetype == 'application/json' 164 | result = json.loads(r.read().decode('utf-8')) 165 | return result['team_type'], result['identifier'] 166 | 167 | @property 168 | def master_key(self): 169 | """(:class:`~.key.PublicKey`) The current master key.""" 170 | path = ('tokens', self.token_id, 'masterkey') 171 | headers = {'Accept': 'text/plain'} 172 | with self.request('GET', path, headers=headers) as r: 173 | if r.code == 200: 174 | mimetype, _ = parse_mimetype(r.headers['Content-Type']) 175 | if mimetype == 'text/plain': 176 | return PublicKey.parse_line(r.read()) 177 | raise MasterKeyError('server failed to show the master key') 178 | 179 | @property 180 | def remotes(self): 181 | """(:class:`collections.Mapping`) The map of aliases to remote 182 | addresses. 183 | 184 | """ 185 | logger = self.logger.getChild('remotes') 186 | logger.info('Loading the list of remotes from the Geofront server...', 187 | extra={'user_waiting': True}) 188 | try: 189 | path = ('tokens', self.token_id, 'remotes') 190 | with self.request('GET', path) as r: 191 | assert r.code == 200 192 | mimetype, _ = parse_mimetype(r.headers['Content-Type']) 193 | assert mimetype == 'application/json' 194 | result = json.loads(r.read().decode('utf-8')) 195 | fmt = '{0[user]}@{0[host]}:{0[port]}'.format 196 | logger.info('Total %d remotes.', len(result), 197 | extra={'user_waiting': False}) 198 | return dict((alias, fmt(remote)) 199 | for alias, remote in result.items()) 200 | except: 201 | logger.info('Failed to fetch the list of remotes.', 202 | extra={'user_waiting': False}) 203 | raise 204 | 205 | def authorize(self, alias): 206 | """Temporarily authorize you to access the given remote ``alias``. 207 | A made authorization keeps alive in a minute, and then will be expired. 208 | 209 | """ 210 | logger = self.logger.getChild('authorize') 211 | logger.info('Letting the Geofront server to authorize you to access ' 212 | 'to %s...', alias, extra={'user_waiting': True}) 213 | try: 214 | path = ('tokens', self.token_id, 'remotes', alias) 215 | with self.request('POST', path) as r: 216 | mimetype, _ = parse_mimetype(r.headers['Content-Type']) 217 | assert mimetype == 'application/json' 218 | result = json.loads(r.read().decode('utf-8')) 219 | if r.code == 404 and result.get('error') == 'not-found': 220 | raise RemoteAliasError(result.get('message')) 221 | elif (r.code == 500 and 222 | result.get('error') == 'connection-failure'): 223 | raise RemoteStateError(result.get('message')) 224 | assert r.code == 200 225 | assert result['success'] == 'authorized' 226 | except TokenIdError: 227 | logger.info('Authentication is required.', 228 | extra={'user_waiting': False}) 229 | raise 230 | except: 231 | logger.info('Authorization to %s has failed.', alias, 232 | extra={'user_waiting': False}) 233 | raise 234 | else: 235 | logger.info('Access to %s has authorized! The access will be ' 236 | 'available only for a time.', alias, 237 | extra={'user_waiting': False}) 238 | return '{0[user]}@{0[host]}:{0[port]}'.format(result['remote']) 239 | 240 | def __repr__(self): 241 | return '{0.__module__}.{0.__name__}({1!r})'.format( 242 | type(self), self.server_url 243 | ) 244 | 245 | 246 | class BufferedResponse(io.BytesIO): 247 | """:class:`io.BytesIO` subclass that mimics some interface of 248 | :class:`http.client.HTTPResponse`. 249 | 250 | """ 251 | 252 | def __init__(self, code, headers, *args, **kwargs): 253 | super(BufferedResponse, self).__init__(*args, **kwargs) 254 | self.code = code 255 | self.headers = headers 256 | 257 | 258 | class PublicKeyDict(MutableMapping): 259 | """:class:`dict`-like object that contains public keys.""" 260 | 261 | def __init__(self, client): 262 | self.client = client 263 | 264 | def _request(self, path=(), method='GET', data=None, headers={}): 265 | path = ('tokens', self.client.token_id, 'keys') + path 266 | with self.client.request(method, path, data, headers) as resp: 267 | mimetype, _ = parse_mimetype(resp.headers['Content-Type']) 268 | body = resp.read() 269 | if mimetype == 'application/json': 270 | body = json.loads(body.decode('utf-8')) 271 | error = isinstance(body, dict) and body.get('error') 272 | else: 273 | error = None 274 | return resp.code, body, error 275 | 276 | def __len__(self): 277 | code, body, error = self._request() 278 | assert code == 200 279 | return len(body) 280 | 281 | def __iter__(self): 282 | code, body, error = self._request() 283 | assert code == 200 284 | return iter(body) 285 | 286 | def __getitem__(self, fprint): 287 | if isinstance(fprint, string_types): 288 | code, body, error = self._request((fprint,)) 289 | if not (code == 404 and error == 'not-found'): 290 | return PublicKey.parse_line(body) 291 | raise KeyError(fprint) 292 | 293 | def __setitem__(self, fprint, pkey): 294 | if not isinstance(pkey, PublicKey): 295 | raise TypeError('expected {0.__module__}.{0.__name__}, not ' 296 | '{1!r}'.format(PublicKey, pkey)) 297 | if fprint != pkey.fingerprint: 298 | raise ValueError( 299 | '{0} is not a valid fingerprint of {1!r}'.format(fprint, pkey) 300 | ) 301 | code, body, error = self._request( 302 | method='POST', 303 | data=bytes(pkey), 304 | headers={'Content-Type': 'text/plain'} 305 | ) 306 | if code == 400 and error == 'duplicate-key': 307 | if fprint in self: 308 | return 309 | raise ValueError(fprint + ' is already used by other') 310 | assert code == 201, 'error: ' + error 311 | 312 | def __delitem__(self, fprint): 313 | if isinstance(fprint, string_types): 314 | code, body, error = self._request((fprint,), method='DELETE') 315 | if not (code == 404 and error == 'not-found'): 316 | return 317 | raise KeyError(fprint) 318 | 319 | def items(self): 320 | code, body, error = self._request() 321 | assert code == 200 322 | return [(fprint, PublicKey.parse_line(pkey)) 323 | for fprint, pkey in body.items()] 324 | 325 | def values(self): 326 | code, body, error = self._request() 327 | assert code == 200 328 | return map(PublicKey.parse_line, body.values()) 329 | 330 | 331 | class ProtocolVersionError(Exception): 332 | """Exception that rises when the server version is not compatibile.""" 333 | 334 | #: (:class:`tuple`) The protocol version triple the server sent. 335 | #: Might be :const:`None`. 336 | server_version_info = None 337 | 338 | def __init__(self, server_version_info, *args, **kwargs): 339 | super(ProtocolVersionError, self).__init__(*args, **kwargs) 340 | self.server_version_info = server_version_info 341 | 342 | @property 343 | def server_version(self): 344 | """(:class:`str`) The server version in string.""" 345 | v = self.server_version_info 346 | return v and '{0}.{1}.{2}'.format(*v) 347 | 348 | 349 | class TokenIdError(Exception): 350 | """Exception related to token id.""" 351 | 352 | 353 | class NoTokenIdError(TokenIdError, AttributeError): 354 | """Exception that rises when there's no configured token id.""" 355 | 356 | 357 | class ExpiredTokenIdError(TokenIdError): 358 | """Exception that rises when the used token id is expired.""" 359 | 360 | 361 | class UnfinishedAuthenticationError(TokenIdError): 362 | """Exception that rises when the used token id is not finished 363 | authentication. 364 | 365 | """ 366 | 367 | 368 | class MasterKeyError(Exception): 369 | """Exception related to the master key.""" 370 | 371 | 372 | class RemoteError(Exception): 373 | """Exception related to remote.""" 374 | 375 | 376 | class RemoteAliasError(RemoteError, LookupError): 377 | """Exception that rises when the given remote alias doesn't exist.""" 378 | 379 | 380 | class RemoteStateError(RemoteError): 381 | """Exception that rises when the status of the remote is unavailable.""" 382 | 383 | 384 | if sys.version_info < (3, 3): 385 | class Request(Request): 386 | 387 | superclass = Request 388 | 389 | def __init__(self, url, data=None, headers={}, method=None): 390 | if isinstance(Request, type): 391 | super(Request, self).__init__(url, data, headers) 392 | else: 393 | self.superclass.__init__(self, url, data, headers) 394 | if method is not None: 395 | self.method = method 396 | 397 | def get_method(self): 398 | if hasattr(self, 'method'): 399 | return self.method 400 | return 'GET' if self.data is None else 'POST' 401 | -------------------------------------------------------------------------------- /geofrontcli/key.py: -------------------------------------------------------------------------------- 1 | """:mod:`geofrontcli.key` --- Public keys 2 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 3 | 4 | """ 5 | import base64 6 | import enum 7 | import hashlib 8 | import re 9 | 10 | from six import string_types 11 | 12 | __all__ = 'KeyType', 'PublicKey' 13 | 14 | 15 | class KeyType(enum.Enum): 16 | """SSH key types.""" 17 | 18 | #: (:class:`KeyType`) ECDSA NIST P-256. 19 | ecdsa_ssh2_nistp256 = 'ecdsa-sha2-nistp256' 20 | 21 | #: (:class:`KeyType`) ECDSA NIST P-384. 22 | ecdsa_ssh2_nistp384 = 'ecdsa-sha2-nistp384' 23 | 24 | #: (:class:`KeyType`) ECDSA NIST P-521. 25 | ecdsa_ssh2_nistp521 = 'ecdsa-sha2-nistp521' 26 | 27 | #: (:class:`KeyType`) DSA. 28 | ssh_dss = 'ssh-dss' 29 | 30 | #: (:class:`KeyType`) RSA. 31 | ssh_rsa = 'ssh-rsa' 32 | 33 | def __repr__(self): 34 | return '{0.__module__}.{0.__name__}.{1}'.format( 35 | type(self), 36 | self.name 37 | ) 38 | 39 | 40 | class PublicKey(object): 41 | """Public key for SSH. 42 | 43 | :param keytype: the keytype 44 | :type keytype: :class:`KeyType` 45 | :param key: keyword-only parameter. the raw :class:`bytes` of the key. 46 | it cannot be used together with ``base64_key`` parameter 47 | :type key: :class:`bytes` 48 | :param base64_key: keyword-only parameter. the base64-encoded form 49 | of the key. it cannot be used together with ``key`` 50 | parameter 51 | :type base64_key: :class:`str` 52 | :param comment: keyword-only parameter. an optional comment 53 | :type comment: :class:`str` 54 | 55 | """ 56 | 57 | #: (:class:`KeyType`) The keytype. 58 | keytype = None 59 | 60 | #: (:class:`bytes`) The raw :class:`bytes` of the key. 61 | key = None 62 | 63 | #: (:class:`str`) Optional comment. Note that this is ignored when 64 | #: it's compared to other public key (using :token:`==` or :token`!=`), 65 | #: or hashed (using :func:`hash()` function). 66 | comment = None 67 | 68 | @classmethod 69 | def parse_line(cls, line): 70 | """Parse a line of ``authorized_keys`` list. 71 | 72 | :param line: a line of ``authorized_keys`` list 73 | :type line: :class:`bytes`, :class:`str` 74 | :return: the parsed public key 75 | :rtype: :class:`PublicKey` 76 | :raise ValueError: when the given ``line`` is invalid 77 | 78 | """ 79 | if isinstance(line, bytes) and not isinstance(line, str): 80 | line = line.decode() 81 | if not isinstance(line, string_types): 82 | raise TypeError('line must be a string, not ' + repr(line)) 83 | tup = line.split() 84 | if len(tup) == 2: 85 | keytype, key = tup 86 | comment = None 87 | elif len(tup) == 3: 88 | keytype, key, comment = tup 89 | else: 90 | raise ValueError('line should consist of two or three columns') 91 | return cls(KeyType(keytype), base64_key=key, comment=comment) 92 | 93 | def __init__(self, keytype, key=None, base64_key=None, comment=None): 94 | if not isinstance(keytype, KeyType): 95 | raise TypeError('keytype must be an instance of {0.__module__}.' 96 | '{0.__name__}, not {1!r}'.format(KeyType, keytype)) 97 | elif not (comment is None or isinstance(comment, string_types)): 98 | raise TypeError('comment must a string, not ' + repr(comment)) 99 | self.keytype = keytype 100 | if key and base64_key: 101 | raise TypeError('key and base64_key arguments cannot be set ' 102 | 'at a time') 103 | elif key: 104 | if not isinstance(key, bytes): 105 | raise TypeError('key must be a bytes, not ' + repr(key)) 106 | self.key = key 107 | elif base64_key: 108 | if not isinstance(base64_key, string_types): 109 | raise TypeError('base64_key must be a string, not ' + 110 | repr(base64_key)) 111 | self.base64_key = base64_key 112 | else: 113 | raise TypeError('key or base64_key must be filled') 114 | self.comment = comment if comment and comment.strip() else None 115 | 116 | @property 117 | def base64_key(self): 118 | """(:class:`str`) Base64-encoded form of :attr:`key`.""" 119 | return base64.b64encode(self.key).decode() 120 | 121 | @base64_key.setter 122 | def base64_key(self, base64_key): 123 | if not isinstance(base64_key, bytes) and isinstance(base64_key, str): 124 | base64_key = base64_key.encode() 125 | self.key = base64.b64decode(base64_key) 126 | assert self.key 127 | 128 | @property 129 | def fingerprint(self): 130 | """(:class:`str`) Hexadecimal fingerprint of the :attr:`key`.""" 131 | return re.sub(r'(\w\w)(?!$)', r'\1:', 132 | hashlib.md5(self.key).hexdigest()) 133 | 134 | def __eq__(self, other): 135 | return (isinstance(other, type(self)) and 136 | self.keytype == other.keytype and 137 | self.key == other.key) 138 | 139 | def __ne__(self, other): 140 | return not (self == other) 141 | 142 | def __hash__(self): 143 | return hash((self.keytype, self.key)) 144 | 145 | def __str__(self): 146 | return '{0} {1} {2}'.format( 147 | self.keytype.value, 148 | self.base64_key, 149 | self.comment or '' 150 | ) 151 | 152 | def __bytes__(self): 153 | return str(self).encode() 154 | 155 | def __repr__(self): 156 | fmt = '{0.__module__}.{0.__name__}({1!r}, key={2!r}, comment={3!r})' 157 | return fmt.format(type(self), self.keytype, self.key, self.comment) 158 | -------------------------------------------------------------------------------- /geofrontcli/ssl.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import re 4 | import ssl 5 | 6 | import certifi # noqa: I902 7 | from six.moves.urllib.request import HTTPSHandler 8 | 9 | __all__ = ('create_https_context', 'create_urllib_https_handler', 10 | 'get_https_context_factory') 11 | 12 | 13 | def get_https_context_factory(): 14 | if not hasattr(ssl, 'Purpose'): 15 | return lambda *_, **__: None 16 | if not hasattr(ssl, '_create_default_https_context') or \ 17 | hasattr(ssl, 'get_default_verify_paths') and \ 18 | ssl.get_default_verify_paths()[0] is None: 19 | m = re.match(r'(Open|Libre)SSL (\d+)\.(\d+)\.(\d+)', 20 | ssl.OPENSSL_VERSION) 21 | openssl_version = int(m.group(2)), int(m.group(3)), int(m.group(4)) 22 | if openssl_version < (1, 0, 2) and hasattr(certifi, 'old_where'): 23 | # https://github.com/certifi/python-certifi/issues/26 24 | where = certifi.old_where 25 | else: 26 | where = certifi.where 27 | 28 | def get_https_context(purpose=ssl.Purpose.SERVER_AUTH, 29 | cafile=None, capath=None, cadata=None): 30 | return ssl.create_default_context( 31 | purpose=purpose, 32 | cafile=cafile or where(), 33 | capath=capath, 34 | cadata=cadata 35 | ) 36 | return get_https_context 37 | if hasattr(ssl, '_create_default_https_context'): 38 | return ssl._create_default_https_context 39 | if hasattr(ssl, 'create_default_context'): 40 | return ssl.create_default_context 41 | return lambda *_, **__: None 42 | 43 | 44 | create_https_context = get_https_context_factory() 45 | 46 | 47 | def create_urllib_https_handler(): 48 | context = create_https_context() 49 | try: 50 | return HTTPSHandler(context=context) 51 | except TypeError: 52 | # Older Python versions doesn't have context parameter. 53 | # (Prior to Python 2.7.9/3.4.3 54 | return HTTPSHandler() 55 | -------------------------------------------------------------------------------- /geofrontcli/version.py: -------------------------------------------------------------------------------- 1 | """:mod:`geofrontcli.version` --- Version data 2 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 3 | 4 | """ 5 | from __future__ import print_function 6 | 7 | 8 | #: (:class:`tuple`) The triple of version numbers e.g. ``(1, 2, 3)``. 9 | VERSION_INFO = (0, 4, 5) 10 | 11 | #: (:class:`str`) The version string e.g. ``'1.2.3'``. 12 | VERSION = '{0}.{1}.{2}'.format(*VERSION_INFO) 13 | 14 | #: (:class:`tuple`) The minimum compatible version of server protocol. 15 | MIN_PROTOCOL_VERSION = (0, 2, 0) 16 | 17 | #: (:class:`tuple`) The maximum compatible version of server protocol. 18 | MAX_PROTOCOL_VERSION = (0, 4, 999) 19 | 20 | 21 | if __name__ == '__main__': 22 | print(VERSION) 23 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import sys 3 | import warnings 4 | 5 | try: 6 | from setuptools import find_packages, setup 7 | except ImportError: 8 | from ez_setup import use_setuptools 9 | use_setuptools() 10 | from setuptools import find_packages, setup 11 | 12 | from geofrontcli.version import VERSION 13 | 14 | 15 | def readme(): 16 | try: 17 | with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as f: 18 | return f.read() 19 | except (IOError, OSError): 20 | return '' 21 | 22 | 23 | install_requires = { 24 | 'certifi', 25 | 'iterfzf >= 0.2.0.16.7, < 1.0.0.0.0', 26 | 'keyring >= 3.7', 27 | 'logging-spinner >= 0.2.1', 28 | 'six', 29 | } 30 | 31 | below_py34_requires = { 32 | 'enum34', 33 | } 34 | 35 | win32_requires = { 36 | 'pypiwin32', 37 | } 38 | 39 | if sys.version_info < (3, 4): 40 | install_requires.update(below_py34_requires) 41 | 42 | if sys.platform == 'win32': 43 | install_requires.update(win32_requires) 44 | 45 | 46 | setup( 47 | name='geofront-cli', 48 | version=VERSION, 49 | description='CLI client for Geofront, a simple SSH key management server', 50 | long_description=readme(), 51 | url='https://github.com/spoqa/geofront-cli', 52 | author='Hong Minhee', 53 | author_email='hongminhee' '@' 'member.fsf.org', 54 | maintainer='Spoqa', 55 | maintainer_email='dev' '@' 'spoqa.com', 56 | license='GPLv3 or later', 57 | packages=find_packages(exclude=['tests']), 58 | entry_points=''' 59 | [console_scripts] 60 | geofront-cli = geofrontcli.cli:main 61 | gfg = geofrontcli.cli:main_go 62 | ''', 63 | install_requires=list(install_requires), 64 | extras_require={ 65 | ":python_version<'3.4'": list(below_py34_requires), 66 | ":sys_platform=='win32'": list(win32_requires), 67 | }, 68 | classifiers=[ 69 | 'Development Status :: 4 - Beta', 70 | 'Environment :: Console', 71 | 'Intended Audience :: Developers', 72 | 'Intended Audience :: System Administrators', 73 | 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', # noqa: E501 74 | 'Operating System :: POSIX', 75 | 'Programming Language :: Python :: 2', 76 | 'Programming Language :: Python :: 2.7', 77 | 'Programming Language :: Python :: 3', 78 | 'Programming Language :: Python :: 3.3', 79 | 'Programming Language :: Python :: 3.4', 80 | 'Programming Language :: Python :: 3.5', 81 | 'Programming Language :: Python :: 3.6', 82 | 'Programming Language :: Python :: Implementation :: CPython', 83 | 'Programming Language :: Python :: Implementation :: PyPy', 84 | 'Topic :: System :: Systems Administration :: Authentication/Directory', # noqa: E501 85 | 'Topic :: Utilities' 86 | ] 87 | ) 88 | 89 | 90 | if 'bdist_wheel' in sys.argv and ( 91 | below_py34_requires.issubset(install_requires) or 92 | win32_requires.issubset(install_requires)): 93 | warnings.warn('Building wheels on Windows or using below Python 3.4 is ' 94 | 'not recommended since platform-specific dependencies can ' 95 | 'be merged into common dependencies:\n' + 96 | '\n'.join('- ' + i for i in install_requires)) 97 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geofront-auth/geofront-cli/3ab01464f10c382355a4b7f45f06b0db560b1249/tests/__init__.py -------------------------------------------------------------------------------- /tests/cli_test.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from pytest import mark 4 | 5 | from geofrontcli.cli import fix_mac_codesign 6 | 7 | 8 | @mark.skipif(sys.platform != 'darwin', reason='Useful only for macOS') 9 | def test_fix_mac_codesign(): 10 | try: 11 | fix_mac_codesign() 12 | except SystemExit: 13 | pass 14 | -------------------------------------------------------------------------------- /tests/client_test.py: -------------------------------------------------------------------------------- 1 | from geofrontcli.client import parse_mimetype 2 | 3 | 4 | def test_parse_mimetype(): 5 | assert parse_mimetype('text/plain') == ('text/plain', []) 6 | assert (parse_mimetype('text/html; charset=utf-8') == 7 | ('text/html', ['charset=utf-8'])) 8 | -------------------------------------------------------------------------------- /tests/key_test.py: -------------------------------------------------------------------------------- 1 | from pytest import fixture, mark 2 | 3 | from geofrontcli.key import KeyType, PublicKey 4 | 5 | 6 | @mark.parametrize('as_bytes', [True, False]) 7 | def test_parse_line(as_bytes): 8 | line = ( 9 | 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCom2CDLekY6AVGexhkjHn0t4uZGelVn' 10 | 'AI2NN7jkRIkoFp+LH+wwSjYILZguMAGZxY203/L7WIurFHDdTWCC08YaQo6fgDyyxcExy' 11 | 'Yxodm05BTKIWRPPOyl6AYt+NOfbPLe2OK4ywC3NicmQtafa2zysnrBAVZ1YUVyizIx2b7' 12 | 'SxdCL25nf4t4MS+3U32JIhRY7cXEgqa32bvomZKGRY5J+GlMeSN1rgra+/wQ+BKSaGvk2' 13 | '7mV6dF5Xzla+FL9qjaN131e9znyMNvuyvb6a/DwHmMkq+naXzY/5M3f4WJFVD1YkDP5Cq' 14 | 'VxLhOKj1FCzYChWGytlKZ45CeYsvSdrTWA5 dahlia@hongminhee-thinkpad-e435' 15 | ) 16 | if as_bytes: 17 | line = line.encode() 18 | key = PublicKey.parse_line(line) 19 | assert isinstance(key, PublicKey) 20 | assert key.keytype == KeyType.ssh_rsa 21 | assert key.key == ( 22 | b'\x00\x00\x00\x07\x73\x73\x68\x2d\x72\x73\x61\x00\x00\x00\x03\x01\x00' 23 | b'\x01\x00\x00\x01\x01\x00\xa8\x9b\x60\x83\x2d\xe9\x18\xe8\x05\x46\x7b' 24 | b'\x18\x64\x8c\x79\xf4\xb7\x8b\x99\x19\xe9\x55\x9c\x02\x36\x34\xde\xe3' 25 | b'\x91\x12\x24\xa0\x5a\x7e\x2c\x7f\xb0\xc1\x28\xd8\x20\xb6\x60\xb8\xc0' 26 | b'\x06\x67\x16\x36\xd3\x7f\xcb\xed\x62\x2e\xac\x51\xc3\x75\x35\x82\x0b' 27 | b'\x4f\x18\x69\x0a\x3a\x7e\x00\xf2\xcb\x17\x04\xc7\x26\x31\xa1\xd9\xb4' 28 | b'\xe4\x14\xca\x21\x64\x4f\x3c\xec\xa5\xe8\x06\x2d\xf8\xd3\x9f\x6c\xf2' 29 | b'\xde\xd8\xe2\xb8\xcb\x00\xb7\x36\x27\x26\x42\xd6\x9f\x6b\x6c\xf2\xb2' 30 | b'\x7a\xc1\x01\x56\x75\x61\x45\x72\x8b\x32\x31\xd9\xbe\xd2\xc5\xd0\x8b' 31 | b'\xdb\x99\xdf\xe2\xde\x0c\x4b\xed\xd4\xdf\x62\x48\x85\x16\x3b\x71\x71' 32 | b'\x20\xa9\xad\xf6\x6e\xfa\x26\x64\xa1\x91\x63\x92\x7e\x1a\x53\x1e\x48' 33 | b'\xdd\x6b\x82\xb6\xbe\xff\x04\x3e\x04\xa4\x9a\x1a\xf9\x36\xee\x65\x7a' 34 | b'\x74\x5e\x57\xce\x56\xbe\x14\xbf\x6a\x8d\xa3\x75\xdf\x57\xbd\xce\x7c' 35 | b'\x8c\x36\xfb\xb2\xbd\xbe\x9a\xfc\x3c\x07\x98\xc9\x2a\xfa\x76\x97\xcd' 36 | b'\x8f\xf9\x33\x77\xf8\x58\x91\x55\x0f\x56\x24\x0c\xfe\x42\xa9\x5c\x4b' 37 | b'\x84\xe2\xa3\xd4\x50\xb3\x60\x28\x56\x1b\x2b\x65\x29\x9e\x39\x09\xe6' 38 | b'\x2c\xbd\x27\x6b\x4d\x60\x39' 39 | ) 40 | assert key.comment == 'dahlia@hongminhee-thinkpad-e435' 41 | 42 | 43 | @fixture 44 | def fx_public_key(): 45 | return PublicKey( 46 | KeyType.ssh_rsa, 47 | base64_key='AAAAB3NzaC1yc2EAAAABIwAAAQEA0ql70Tsi8ToDGm+gkkRGv12Eb15QSg' 48 | 'dVQeIFbasK+yHNITAOVHtbM3nlUTIxFh7sSga7UmEjCya0ljU0GJ+zvnFO' 49 | 'xKvRypBoUY38W8XkR3f2IJQwbWE7/t4Vs4DViramrZr/wnQtRstLZRncIj' 50 | '307ApQuB18uedbtreGdg+cd75/KfTvDc3L17ZYlgdmJ+tTdzTi5mYbiPmt' 51 | 'n631Qm8/OCBazwUSfidRlG1SN97QJdV5ZFLNN+3BRR7RIRzYZ/2KEJqiOI' 52 | '5nqi3TEiPeq49/LJElu4tdJ8icXT7COrGllnhBbpZdxRM26hhVXv62vOTQ' 53 | 'wXm1fumg0PgMACP2S1WVNw==', 54 | comment='dahlia@Hong-Minhees-MacBook-Pro.local' 55 | ) 56 | 57 | 58 | @fixture 59 | def fx_equivalent_key(fx_public_key): 60 | return PublicKey( 61 | KeyType.ssh_rsa, 62 | key=fx_public_key.key, 63 | comment=fx_public_key.comment 64 | ) 65 | 66 | 67 | @fixture 68 | def fx_equivalent_key_except_comment(fx_public_key): 69 | return PublicKey(KeyType.ssh_rsa, key=fx_public_key.key) 70 | 71 | 72 | @fixture 73 | def fx_different_keys(fx_public_key): 74 | return frozenset([ 75 | PublicKey(KeyType.ssh_rsa, key=b'...'), 76 | PublicKey(KeyType.ssh_dss, key=fx_public_key.key), 77 | PublicKey(KeyType.ssh_dss, key=b'...') 78 | ]) 79 | 80 | 81 | def test_public_key_eq(fx_public_key, fx_equivalent_key, 82 | fx_equivalent_key_except_comment, fx_different_keys): 83 | assert fx_public_key == fx_equivalent_key 84 | assert fx_public_key == fx_equivalent_key_except_comment 85 | for key in fx_different_keys: 86 | assert not (fx_public_key == key) 87 | 88 | 89 | def test_public_key_ne(fx_public_key, fx_equivalent_key, 90 | fx_equivalent_key_except_comment, fx_different_keys): 91 | assert not (fx_public_key != fx_equivalent_key) 92 | assert not (fx_public_key != fx_equivalent_key_except_comment) 93 | for key in fx_different_keys: 94 | assert fx_public_key != key 95 | 96 | 97 | def test_public_key_hash(fx_public_key, fx_equivalent_key, 98 | fx_equivalent_key_except_comment, fx_different_keys): 99 | assert hash(fx_public_key) == hash(fx_equivalent_key) 100 | assert hash(fx_public_key) == hash(fx_equivalent_key_except_comment) 101 | for key in fx_different_keys: 102 | assert hash(fx_public_key) != hash(key) 103 | 104 | 105 | @mark.parametrize('as_bytes', [True, False]) 106 | def test_public_key_str(fx_public_key, as_bytes): 107 | expected = ( 108 | 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA0ql70Tsi8ToDGm+gkkRGv12Eb15QSgdVQ' 109 | 'eIFbasK+yHNITAOVHtbM3nlUTIxFh7sSga7UmEjCya0ljU0GJ+zvnFOxKvRypBoUY38W8' 110 | 'XkR3f2IJQwbWE7/t4Vs4DViramrZr/wnQtRstLZRncIj307ApQuB18uedbtreGdg+cd75' 111 | '/KfTvDc3L17ZYlgdmJ+tTdzTi5mYbiPmtn631Qm8/OCBazwUSfidRlG1SN97QJdV5ZFLN' 112 | 'N+3BRR7RIRzYZ/2KEJqiOI5nqi3TEiPeq49/LJElu4tdJ8icXT7COrGllnhBbpZdxRM26' 113 | 'hhVXv62vOTQwXm1fumg0PgMACP2S1WVNw== dahlia@Hong-Minhees-MacBook-Pro.l' 114 | 'ocal' 115 | ) 116 | if as_bytes: 117 | assert bytes(fx_public_key) == expected.encode() 118 | else: 119 | assert str(fx_public_key) == expected 120 | 121 | 122 | @mark.parametrize('as_bytes', [True, False]) 123 | def test_public_key_str_without_comment(fx_equivalent_key_except_comment, 124 | as_bytes): 125 | expected = ( 126 | 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA0ql70Tsi8ToDGm+gkkRGv12Eb15QSgdVQ' 127 | 'eIFbasK+yHNITAOVHtbM3nlUTIxFh7sSga7UmEjCya0ljU0GJ+zvnFOxKvRypBoUY38W8' 128 | 'XkR3f2IJQwbWE7/t4Vs4DViramrZr/wnQtRstLZRncIj307ApQuB18uedbtreGdg+cd75' 129 | '/KfTvDc3L17ZYlgdmJ+tTdzTi5mYbiPmtn631Qm8/OCBazwUSfidRlG1SN97QJdV5ZFLN' 130 | 'N+3BRR7RIRzYZ/2KEJqiOI5nqi3TEiPeq49/LJElu4tdJ8icXT7COrGllnhBbpZdxRM26' 131 | 'hhVXv62vOTQwXm1fumg0PgMACP2S1WVNw==' 132 | ) 133 | if as_bytes: 134 | expected = expected.encode() 135 | assert bytes(fx_equivalent_key_except_comment).strip() == expected 136 | else: 137 | assert str(fx_equivalent_key_except_comment).strip() == expected 138 | 139 | 140 | def test_public_key_fingerprint(fx_public_key): 141 | assert (fx_public_key.fingerprint == 142 | 'f5:6e:03:1c:cd:2c:84:64:d7:94:18:8b:79:60:11:df') 143 | -------------------------------------------------------------------------------- /tests/ssl_test.py: -------------------------------------------------------------------------------- 1 | import ssl 2 | 3 | from geofrontcli.ssl import (create_https_context, create_urllib_https_handler, 4 | get_https_context_factory) 5 | 6 | 7 | def test_get_https_context_factory(): 8 | factory = get_https_context_factory() 9 | context = factory() 10 | assert context is None or isinstance(context, ssl.SSLContext) 11 | 12 | 13 | def test_create_https_context(): 14 | context = create_https_context() 15 | assert context is None or isinstance(context, ssl.SSLContext) 16 | 17 | 18 | def test_create_urllib_https_handler(): 19 | create_urllib_https_handler() 20 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = pypy, py27, py33, py34, py35, py36 3 | minversion = 1.6.0 4 | 5 | [testenv] 6 | deps = 7 | flake8 >= 3.3.0 8 | flake8-import-order-spoqa 9 | pytest >= 3.0.7, < 3.1.0 10 | pytest-flake8 >= 0.8.1, < 0.9.0 11 | testtools 12 | # testtools is required by dirspec 13 | commands = 14 | flake8 geofrontcli 15 | py.test {posargs:--durations=5} 16 | 17 | [pytest] 18 | addopts = --ff --flake8 19 | testpaths = tests/ geofrontcli/ README.rst 20 | 21 | [flake8] 22 | exclude = .tox 23 | import-order-style = spoqa 24 | application-import-names = geofrontcli, tests 25 | --------------------------------------------------------------------------------