├── .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 |
--------------------------------------------------------------------------------