├── .gitignore
├── LICENSE
├── README.md
├── example.py
├── poetry.lock
├── pyproject.toml
├── pytest.ini
├── tests
├── __init__.py
└── test_schema.py
└── thinq2
├── __init__.py
├── client
├── __init__.py
├── base.py
├── common.py
├── gateway.py
├── oauth.py
├── objectstore.py
└── thinq.py
├── controller
├── __init__.py
├── auth.py
├── device.py
├── mqtt.py
└── thinq.py
├── model
├── __init__.py
├── auth.py
├── common.py
├── config.py
├── device
│ ├── __init__.py
│ ├── base.py
│ └── laundry.py
├── gateway.py
├── mqtt.py
└── thinq.py
├── schema.py
└── util
├── __init__.py
├── filesystem.py
└── uuid.py
/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__/
2 | *.pyc
3 | .*.sw?
4 | *.egg-info/
5 | .venv/
6 | .vscode/
7 | .pytest_cache
8 | # output by example script
9 | state.json
10 |
--------------------------------------------------------------------------------
/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.md:
--------------------------------------------------------------------------------
1 | # thinq2-python
2 |
3 | This is a reverse-engineered client for the LG ThinQ v2 IoT protocol.
4 |
5 | If you are working with v1 devices, try [wideq](https://github.com/sampsyo/wideq),
6 | which inspired this project.
7 |
8 | ## Work in progress!
9 |
10 | This is very much a **work in progress**.
11 |
12 | There are no unit tests, there is no documentation, there is no defined API and
13 | breaking changes are almost guaranteed to happen. Fun times!
14 |
15 | ## Development
16 |
17 | This project uses [poetry](https://python-poetry.org/) for dependency management.
18 |
19 | To configure a development environment, run `poetry install`.
20 |
21 | ## Running the Example
22 |
23 | There is currently no documentation, but you can use `example.py` to demo the
24 | codebase, and its code shows basic usage. The `COUNTRY_CODE` and `LANGUAGE_CODE`
25 | environment variables should be set appropriately on first invocation in order
26 | to bootstrap the client. These will be stored in state for future invocations.
27 |
28 | Example:
29 |
30 | poetry install
31 | COUNTRY_CODE=US LANGUAGE_CODE=en-US poetry run python example.py
32 |
33 | Example (Windows Powershell):
34 |
35 | $env:COUNTRY_CODE=US
36 | $env:LANGUAGE_CODE=en-US
37 | poetry run python example.py
38 |
39 | The example script will bootstrap the application state on first run, walking
40 | you through the OAuth flow. If authentication succeeds, you should see a
41 | display of basic account information and a list of your ThinQv2 devices. The
42 | script will then begin dumping device events received via MQTT. Try turning
43 | devices on/off or otherwise changing their state, and you should see raw events
44 | being sent.
45 |
46 |
47 | ## Contributing
48 |
49 | As this is an early stage prototype, no contribution guidelines are available,
50 | but help is always welcome!
51 |
--------------------------------------------------------------------------------
/example.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import os
3 | import json
4 | import signal
5 |
6 | from thinq2.controller.auth import ThinQAuth
7 | from thinq2.controller.thinq import ThinQ
8 |
9 | LANGUAGE_CODE = os.environ.get("LANGUAGE_CODE", "ko-KR")
10 | COUNTRY_CODE = os.environ.get("COUNTRY_CODE", "KR")
11 | STATE_FILE = os.environ.get("STATE_FILE", "state.json")
12 |
13 | #############################################################################
14 | # load from existing state or create a new client #
15 | #############################################################################
16 | if os.path.exists(STATE_FILE):
17 | with open(STATE_FILE, "r") as f:
18 | thinq = ThinQ(json.load(f))
19 | else:
20 | auth = ThinQAuth(language_code=LANGUAGE_CODE, country_code=COUNTRY_CODE)
21 |
22 | print("No state file found, starting new client session.\n")
23 | print(
24 | "Please set the following environment variables if the default is not correct:\n"
25 | )
26 | print("LANGUAGE_CODE={} COUNTRY_CODE={}\n".format(LANGUAGE_CODE, COUNTRY_CODE))
27 | print("Log in here:\n")
28 | print(auth.oauth_login_url)
29 | print("\nThen paste the URL to which the browser is redirected:\n")
30 |
31 | callback_url = input()
32 | auth.set_token_from_url(callback_url)
33 | thinq = ThinQ(auth=auth)
34 |
35 | print("\n")
36 |
37 |
38 | def save_state():
39 | with open(STATE_FILE, "w") as f:
40 | json.dump(vars(thinq), f)
41 |
42 |
43 | save_state()
44 |
45 | #############################################################################
46 | # state is easily serialized in dict form, as in this shutdown handler #
47 | #############################################################################
48 | def shutdown(sig, frame):
49 | print("\nCaught SIGINT, saving application state.")
50 | exit(0)
51 |
52 |
53 | signal.signal(signal.SIGINT, shutdown)
54 |
55 | #############################################################################
56 | # display some information about the user's account/devices #
57 | #############################################################################
58 | devices = thinq.mqtt.thinq_client.get_devices()
59 |
60 | if len(devices.items) == 0:
61 | print("No devices found!")
62 | print("If you are using ThinQ v1 devices, try https://github.com/sampsyo/wideq")
63 | exit(1)
64 |
65 | print("UserID: {}".format(thinq.auth.profile.user_id))
66 | print("User #: {}\n".format(thinq.auth.profile.user_no))
67 | print("Devices:\n")
68 |
69 |
70 | for device in devices.items:
71 | print("{}: {} (model {})".format(device.device_id, device.alias, device.model_name))
72 |
73 | #############################################################################
74 | # example of raw MQTT access #
75 | #############################################################################
76 |
77 | print("\nListening for device events. Use Ctrl-C/SIGINT to quit.\n")
78 |
79 | thinq.mqtt.on_message = lambda client, userdata, msg: print(msg.payload)
80 | thinq.mqtt.connect()
81 | thinq.mqtt.loop_forever()
82 |
--------------------------------------------------------------------------------
/poetry.lock:
--------------------------------------------------------------------------------
1 | [[package]]
2 | category = "dev"
3 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
4 | name = "appdirs"
5 | optional = false
6 | python-versions = "*"
7 | version = "1.4.4"
8 |
9 | [[package]]
10 | category = "dev"
11 | description = "An abstract syntax tree for Python with inference support."
12 | name = "astroid"
13 | optional = false
14 | python-versions = ">=3.5.*"
15 | version = "2.3.3"
16 |
17 | [package.dependencies]
18 | lazy-object-proxy = ">=1.4.0,<1.5.0"
19 | six = ">=1.12,<2.0"
20 | wrapt = ">=1.11.0,<1.12.0"
21 |
22 | [package.dependencies.typed-ast]
23 | python = "<3.8"
24 | version = ">=1.4.0,<1.5"
25 |
26 | [[package]]
27 | category = "dev"
28 | description = "Atomic file writes."
29 | marker = "sys_platform == \"win32\""
30 | name = "atomicwrites"
31 | optional = false
32 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
33 | version = "1.3.0"
34 |
35 | [[package]]
36 | category = "main"
37 | description = "A dict with attribute-style access"
38 | name = "attrdict"
39 | optional = false
40 | python-versions = "*"
41 | version = "2.0.1"
42 |
43 | [package.dependencies]
44 | six = "*"
45 |
46 | [[package]]
47 | category = "dev"
48 | description = "Classes Without Boilerplate"
49 | name = "attrs"
50 | optional = false
51 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
52 | version = "19.3.0"
53 |
54 | [package.extras]
55 | azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"]
56 | dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"]
57 | docs = ["sphinx", "zope.interface"]
58 | tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"]
59 |
60 | [[package]]
61 | category = "dev"
62 | description = "The uncompromising code formatter."
63 | name = "black"
64 | optional = false
65 | python-versions = ">=3.6"
66 | version = "19.10b0"
67 |
68 | [package.dependencies]
69 | appdirs = "*"
70 | attrs = ">=18.1.0"
71 | click = ">=6.5"
72 | pathspec = ">=0.6,<1"
73 | regex = "*"
74 | toml = ">=0.9.4"
75 | typed-ast = ">=1.4.0"
76 |
77 | [package.extras]
78 | d = ["aiohttp (>=3.3.2)", "aiohttp-cors"]
79 |
80 | [[package]]
81 | category = "main"
82 | description = "Python package for providing Mozilla's CA Bundle."
83 | name = "certifi"
84 | optional = false
85 | python-versions = "*"
86 | version = "2020.4.5.1"
87 |
88 | [[package]]
89 | category = "main"
90 | description = "Foreign Function Interface for Python calling C code."
91 | name = "cffi"
92 | optional = false
93 | python-versions = "*"
94 | version = "1.14.0"
95 |
96 | [package.dependencies]
97 | pycparser = "*"
98 |
99 | [[package]]
100 | category = "main"
101 | description = "Universal encoding detector for Python 2 and 3"
102 | name = "chardet"
103 | optional = false
104 | python-versions = "*"
105 | version = "3.0.4"
106 |
107 | [[package]]
108 | category = "dev"
109 | description = "Composable command line interface toolkit"
110 | name = "click"
111 | optional = false
112 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
113 | version = "7.1.2"
114 |
115 | [[package]]
116 | category = "dev"
117 | description = "Cross-platform colored terminal text."
118 | marker = "sys_platform == \"win32\""
119 | name = "colorama"
120 | optional = false
121 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
122 | version = "0.4.3"
123 |
124 | [[package]]
125 | category = "main"
126 | description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
127 | name = "cryptography"
128 | optional = false
129 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*"
130 | version = "2.9.2"
131 |
132 | [package.dependencies]
133 | cffi = ">=1.8,<1.11.3 || >1.11.3"
134 | six = ">=1.4.1"
135 |
136 | [package.extras]
137 | docs = ["sphinx (>=1.6.5,<1.8.0 || >1.8.0)", "sphinx-rtd-theme"]
138 | docstest = ["doc8", "pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"]
139 | idna = ["idna (>=2.1)"]
140 | pep8test = ["flake8", "flake8-import-order", "pep8-naming"]
141 | test = ["pytest (>=3.6.0,<3.9.0 || >3.9.0,<3.9.1 || >3.9.1,<3.9.2 || >3.9.2)", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,<3.79.2 || >3.79.2)"]
142 |
143 | [[package]]
144 | category = "main"
145 | description = "a toolset to deeply merge python dictionaries."
146 | name = "deepmerge"
147 | optional = false
148 | python-versions = "*"
149 | version = "0.1.0"
150 |
151 | [[package]]
152 | category = "dev"
153 | description = "the modular source code checker: pep8 pyflakes and co"
154 | name = "flake8"
155 | optional = false
156 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7"
157 | version = "3.8.1"
158 |
159 | [package.dependencies]
160 | mccabe = ">=0.6.0,<0.7.0"
161 | pycodestyle = ">=2.6.0a1,<2.7.0"
162 | pyflakes = ">=2.2.0,<2.3.0"
163 |
164 | [package.dependencies.importlib-metadata]
165 | python = "<3.8"
166 | version = "*"
167 |
168 | [[package]]
169 | category = "main"
170 | description = "Internationalized Domain Names in Applications (IDNA)"
171 | name = "idna"
172 | optional = false
173 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
174 | version = "2.9"
175 |
176 | [[package]]
177 | category = "dev"
178 | description = "Read metadata from Python packages"
179 | marker = "python_version < \"3.8\""
180 | name = "importlib-metadata"
181 | optional = false
182 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
183 | version = "1.6.0"
184 |
185 | [package.dependencies]
186 | zipp = ">=0.5"
187 |
188 | [package.extras]
189 | docs = ["sphinx", "rst.linker"]
190 | testing = ["packaging", "importlib-resources"]
191 |
192 | [[package]]
193 | category = "main"
194 | description = "A port of Ruby on Rails inflector to Python"
195 | name = "inflection"
196 | optional = false
197 | python-versions = ">=3.5"
198 | version = "0.4.0"
199 |
200 | [[package]]
201 | category = "dev"
202 | description = "A Python utility / library to sort Python imports."
203 | name = "isort"
204 | optional = false
205 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
206 | version = "4.3.21"
207 |
208 | [package.extras]
209 | pipfile = ["pipreqs", "requirementslib"]
210 | pyproject = ["toml"]
211 | requirements = ["pipreqs", "pip-api"]
212 | xdg_home = ["appdirs (>=1.4.0)"]
213 |
214 | [[package]]
215 | category = "dev"
216 | description = "A fast and thorough lazy object proxy."
217 | name = "lazy-object-proxy"
218 | optional = false
219 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
220 | version = "1.4.3"
221 |
222 | [[package]]
223 | category = "main"
224 | description = "A lightweight library for converting complex datatypes to and from native Python datatypes."
225 | name = "marshmallow"
226 | optional = false
227 | python-versions = ">=3.5"
228 | version = "3.5.1"
229 |
230 | [package.extras]
231 | dev = ["pytest", "pytz", "simplejson", "mypy (0.761)", "flake8 (3.7.9)", "flake8-bugbear (20.1.4)", "pre-commit (>=1.20,<3.0)", "tox"]
232 | docs = ["sphinx (2.4.3)", "sphinx-issues (1.2.0)", "alabaster (0.7.12)", "sphinx-version-warning (1.1.2)"]
233 | lint = ["mypy (0.761)", "flake8 (3.7.9)", "flake8-bugbear (20.1.4)", "pre-commit (>=1.20,<3.0)"]
234 | tests = ["pytest", "pytz", "simplejson"]
235 |
236 | [[package]]
237 | category = "main"
238 | description = "Python library to convert dataclasses into marshmallow schemas."
239 | name = "marshmallow-dataclass"
240 | optional = false
241 | python-versions = ">=3.6"
242 | version = "7.5.0"
243 |
244 | [package.dependencies]
245 | marshmallow = ">=3.0.0,<4.0"
246 | typing-inspect = "*"
247 |
248 | [package.extras]
249 | dev = ["marshmallow-enum", "marshmallow-union", "pre-commit (>=1.18,<2.0)", "sphinx", "pytest", "pytest-mypy-plugins (>=1.2.0)"]
250 | docs = ["sphinx"]
251 | enum = ["marshmallow-enum"]
252 | lint = ["pre-commit (>=1.18,<2.0)"]
253 | tests = ["pytest", "pytest-mypy-plugins (>=1.2.0)"]
254 | union = ["marshmallow-union"]
255 |
256 | [[package]]
257 | category = "main"
258 | description = "Enum field for Marshmallow"
259 | name = "marshmallow-enum"
260 | optional = false
261 | python-versions = "*"
262 | version = "1.5.1"
263 |
264 | [package.dependencies]
265 | marshmallow = ">=2.0.0"
266 |
267 | [[package]]
268 | category = "dev"
269 | description = "McCabe checker, plugin for flake8"
270 | name = "mccabe"
271 | optional = false
272 | python-versions = "*"
273 | version = "0.6.1"
274 |
275 | [[package]]
276 | category = "dev"
277 | description = "More routines for operating on iterables, beyond itertools"
278 | name = "more-itertools"
279 | optional = false
280 | python-versions = ">=3.5"
281 | version = "8.2.0"
282 |
283 | [[package]]
284 | category = "main"
285 | description = "Experimental type system extensions for programs checked with the mypy typechecker."
286 | name = "mypy-extensions"
287 | optional = false
288 | python-versions = "*"
289 | version = "0.4.3"
290 |
291 | [[package]]
292 | category = "dev"
293 | description = "Core utilities for Python packages"
294 | name = "packaging"
295 | optional = false
296 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
297 | version = "20.3"
298 |
299 | [package.dependencies]
300 | pyparsing = ">=2.0.2"
301 | six = "*"
302 |
303 | [[package]]
304 | category = "main"
305 | description = "MQTT version 3.1.1 client class"
306 | name = "paho-mqtt"
307 | optional = false
308 | python-versions = "*"
309 | version = "1.5.0"
310 |
311 | [package.extras]
312 | proxy = ["pysocks"]
313 |
314 | [[package]]
315 | category = "dev"
316 | description = "Utility library for gitignore style pattern matching of file paths."
317 | name = "pathspec"
318 | optional = false
319 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
320 | version = "0.8.0"
321 |
322 | [[package]]
323 | category = "dev"
324 | description = "plugin and hook calling mechanisms for python"
325 | name = "pluggy"
326 | optional = false
327 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
328 | version = "0.13.1"
329 |
330 | [package.dependencies]
331 | [package.dependencies.importlib-metadata]
332 | python = "<3.8"
333 | version = ">=0.12"
334 |
335 | [package.extras]
336 | dev = ["pre-commit", "tox"]
337 |
338 | [[package]]
339 | category = "dev"
340 | description = "library with cross-python path, ini-parsing, io, code, log facilities"
341 | name = "py"
342 | optional = false
343 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
344 | version = "1.8.1"
345 |
346 | [[package]]
347 | category = "dev"
348 | description = "Python style guide checker"
349 | name = "pycodestyle"
350 | optional = false
351 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
352 | version = "2.6.0"
353 |
354 | [[package]]
355 | category = "main"
356 | description = "C parser in Python"
357 | name = "pycparser"
358 | optional = false
359 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
360 | version = "2.20"
361 |
362 | [[package]]
363 | category = "dev"
364 | description = "passive checker of Python programs"
365 | name = "pyflakes"
366 | optional = false
367 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
368 | version = "2.2.0"
369 |
370 | [[package]]
371 | category = "dev"
372 | description = "python code static checker"
373 | name = "pylint"
374 | optional = false
375 | python-versions = ">=3.5.*"
376 | version = "2.4.4"
377 |
378 | [package.dependencies]
379 | astroid = ">=2.3.0,<2.4"
380 | colorama = "*"
381 | isort = ">=4.2.5,<5"
382 | mccabe = ">=0.6,<0.7"
383 |
384 | [[package]]
385 | category = "main"
386 | description = "Python wrapper module around the OpenSSL library"
387 | name = "pyopenssl"
388 | optional = false
389 | python-versions = "*"
390 | version = "19.1.0"
391 |
392 | [package.dependencies]
393 | cryptography = ">=2.8"
394 | six = ">=1.5.2"
395 |
396 | [package.extras]
397 | docs = ["sphinx", "sphinx-rtd-theme"]
398 | test = ["flaky", "pretend", "pytest (>=3.0.1)"]
399 |
400 | [[package]]
401 | category = "dev"
402 | description = "Python parsing module"
403 | name = "pyparsing"
404 | optional = false
405 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
406 | version = "2.4.7"
407 |
408 | [[package]]
409 | category = "dev"
410 | description = "pytest: simple powerful testing with Python"
411 | name = "pytest"
412 | optional = false
413 | python-versions = ">=3.5"
414 | version = "5.4.1"
415 |
416 | [package.dependencies]
417 | atomicwrites = ">=1.0"
418 | attrs = ">=17.4.0"
419 | colorama = "*"
420 | more-itertools = ">=4.0.0"
421 | packaging = "*"
422 | pluggy = ">=0.12,<1.0"
423 | py = ">=1.5.0"
424 | wcwidth = "*"
425 |
426 | [package.dependencies.importlib-metadata]
427 | python = "<3.8"
428 | version = ">=0.12"
429 |
430 | [package.extras]
431 | checkqa-mypy = ["mypy (v0.761)"]
432 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"]
433 |
434 | [[package]]
435 | category = "dev"
436 | description = "Alternative regular expression module, to replace re."
437 | name = "regex"
438 | optional = false
439 | python-versions = "*"
440 | version = "2020.5.14"
441 |
442 | [[package]]
443 | category = "main"
444 | description = "Python HTTP for Humans."
445 | name = "requests"
446 | optional = false
447 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
448 | version = "2.23.0"
449 |
450 | [package.dependencies]
451 | certifi = ">=2017.4.17"
452 | chardet = ">=3.0.2,<4"
453 | idna = ">=2.5,<3"
454 | urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26"
455 |
456 | [package.extras]
457 | security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"]
458 | socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"]
459 |
460 | [[package]]
461 | category = "dev"
462 | description = "a python refactoring library..."
463 | name = "rope"
464 | optional = false
465 | python-versions = "*"
466 | version = "0.17.0"
467 |
468 | [package.extras]
469 | dev = ["pytest"]
470 |
471 | [[package]]
472 | category = "main"
473 | description = "Python 2 and 3 compatibility utilities"
474 | name = "six"
475 | optional = false
476 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
477 | version = "1.14.0"
478 |
479 | [[package]]
480 | category = "dev"
481 | description = "Python Library for Tom's Obvious, Minimal Language"
482 | name = "toml"
483 | optional = false
484 | python-versions = "*"
485 | version = "0.10.1"
486 |
487 | [[package]]
488 | category = "dev"
489 | description = "a fork of Python 2 and 3 ast modules with type comment support"
490 | name = "typed-ast"
491 | optional = false
492 | python-versions = "*"
493 | version = "1.4.1"
494 |
495 | [[package]]
496 | category = "main"
497 | description = "Backported and Experimental Type Hints for Python 3.5+"
498 | name = "typing-extensions"
499 | optional = false
500 | python-versions = "*"
501 | version = "3.7.4.2"
502 |
503 | [[package]]
504 | category = "main"
505 | description = "Runtime inspection utilities for typing module."
506 | name = "typing-inspect"
507 | optional = false
508 | python-versions = "*"
509 | version = "0.5.0"
510 |
511 | [package.dependencies]
512 | mypy-extensions = ">=0.3.0"
513 | typing-extensions = ">=3.7.4"
514 |
515 | [[package]]
516 | category = "main"
517 | description = "A Declarative HTTP Client for Python."
518 | name = "uplink"
519 | optional = false
520 | python-versions = "*"
521 | version = "0.9.1"
522 |
523 | [package.dependencies]
524 | requests = ">=2.18.0"
525 | six = ">=1.12.0"
526 | uritemplate = ">=3.0.0"
527 |
528 | [package.extras]
529 | aiohttp = ["aiohttp (>=2.3.0)"]
530 | marshmallow = ["marshmallow (>=2.15.0)"]
531 | tests = ["pytest (4.6.5)", "pytest-mock", "pytest-cov", "pytest-twisted"]
532 | twisted = ["twisted (>=17.1.0)", "twisted (<=17.9.0)", "twisted (<=19.2.1)"]
533 | typing = ["typing (>=3.6.4)"]
534 |
535 | [[package]]
536 | category = "main"
537 | description = "URI templates"
538 | name = "uritemplate"
539 | optional = false
540 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
541 | version = "3.0.1"
542 |
543 | [[package]]
544 | category = "main"
545 | description = "HTTP library with thread-safe connection pooling, file post, and more."
546 | name = "urllib3"
547 | optional = false
548 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
549 | version = "1.25.8"
550 |
551 | [package.extras]
552 | brotli = ["brotlipy (>=0.6.0)"]
553 | secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
554 | socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"]
555 |
556 | [[package]]
557 | category = "dev"
558 | description = "Measures number of Terminal column cells of wide-character codes"
559 | name = "wcwidth"
560 | optional = false
561 | python-versions = "*"
562 | version = "0.1.9"
563 |
564 | [[package]]
565 | category = "dev"
566 | description = "Module for decorators, wrappers and monkey patching."
567 | name = "wrapt"
568 | optional = false
569 | python-versions = "*"
570 | version = "1.11.2"
571 |
572 | [[package]]
573 | category = "dev"
574 | description = "Backport of pathlib-compatible object wrapper for zip files"
575 | marker = "python_version < \"3.8\""
576 | name = "zipp"
577 | optional = false
578 | python-versions = ">=3.6"
579 | version = "3.1.0"
580 |
581 | [package.extras]
582 | docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"]
583 | testing = ["jaraco.itertools", "func-timeout"]
584 |
585 | [metadata]
586 | content-hash = "45b93b5b0b890d59981e3b3b8db5960c8f1ec9be70c01fc0277c6eaeabb810ed"
587 | python-versions = "^3.7"
588 |
589 | [metadata.files]
590 | appdirs = [
591 | {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"},
592 | {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"},
593 | ]
594 | astroid = [
595 | {file = "astroid-2.3.3-py3-none-any.whl", hash = "sha256:840947ebfa8b58f318d42301cf8c0a20fd794a33b61cc4638e28e9e61ba32f42"},
596 | {file = "astroid-2.3.3.tar.gz", hash = "sha256:71ea07f44df9568a75d0f354c49143a4575d90645e9fead6dfb52c26a85ed13a"},
597 | ]
598 | atomicwrites = [
599 | {file = "atomicwrites-1.3.0-py2.py3-none-any.whl", hash = "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4"},
600 | {file = "atomicwrites-1.3.0.tar.gz", hash = "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"},
601 | ]
602 | attrdict = [
603 | {file = "attrdict-2.0.1-py2.py3-none-any.whl", hash = "sha256:9432e3498c74ff7e1b20b3d93b45d766b71cbffa90923496f82c4ae38b92be34"},
604 | {file = "attrdict-2.0.1.tar.gz", hash = "sha256:35c90698b55c683946091177177a9e9c0713a0860f0e049febd72649ccd77b70"},
605 | ]
606 | attrs = [
607 | {file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"},
608 | {file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"},
609 | ]
610 | black = [
611 | {file = "black-19.10b0-py36-none-any.whl", hash = "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b"},
612 | {file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"},
613 | ]
614 | certifi = [
615 | {file = "certifi-2020.4.5.1-py2.py3-none-any.whl", hash = "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304"},
616 | {file = "certifi-2020.4.5.1.tar.gz", hash = "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519"},
617 | ]
618 | cffi = [
619 | {file = "cffi-1.14.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:1cae98a7054b5c9391eb3249b86e0e99ab1e02bb0cc0575da191aedadbdf4384"},
620 | {file = "cffi-1.14.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:cf16e3cf6c0a5fdd9bc10c21687e19d29ad1fe863372b5543deaec1039581a30"},
621 | {file = "cffi-1.14.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:f2b0fa0c01d8a0c7483afd9f31d7ecf2d71760ca24499c8697aeb5ca37dc090c"},
622 | {file = "cffi-1.14.0-cp27-cp27m-win32.whl", hash = "sha256:99f748a7e71ff382613b4e1acc0ac83bf7ad167fb3802e35e90d9763daba4d78"},
623 | {file = "cffi-1.14.0-cp27-cp27m-win_amd64.whl", hash = "sha256:c420917b188a5582a56d8b93bdd8e0f6eca08c84ff623a4c16e809152cd35793"},
624 | {file = "cffi-1.14.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:399aed636c7d3749bbed55bc907c3288cb43c65c4389964ad5ff849b6370603e"},
625 | {file = "cffi-1.14.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:cab50b8c2250b46fe738c77dbd25ce017d5e6fb35d3407606e7a4180656a5a6a"},
626 | {file = "cffi-1.14.0-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:001bf3242a1bb04d985d63e138230802c6c8d4db3668fb545fb5005ddf5bb5ff"},
627 | {file = "cffi-1.14.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:e56c744aa6ff427a607763346e4170629caf7e48ead6921745986db3692f987f"},
628 | {file = "cffi-1.14.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:b8c78301cefcf5fd914aad35d3c04c2b21ce8629b5e4f4e45ae6812e461910fa"},
629 | {file = "cffi-1.14.0-cp35-cp35m-win32.whl", hash = "sha256:8c0ffc886aea5df6a1762d0019e9cb05f825d0eec1f520c51be9d198701daee5"},
630 | {file = "cffi-1.14.0-cp35-cp35m-win_amd64.whl", hash = "sha256:8a6c688fefb4e1cd56feb6c511984a6c4f7ec7d2a1ff31a10254f3c817054ae4"},
631 | {file = "cffi-1.14.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:95cd16d3dee553f882540c1ffe331d085c9e629499ceadfbda4d4fde635f4b7d"},
632 | {file = "cffi-1.14.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:66e41db66b47d0d8672d8ed2708ba91b2f2524ece3dee48b5dfb36be8c2f21dc"},
633 | {file = "cffi-1.14.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:028a579fc9aed3af38f4892bdcc7390508adabc30c6af4a6e4f611b0c680e6ac"},
634 | {file = "cffi-1.14.0-cp36-cp36m-win32.whl", hash = "sha256:cef128cb4d5e0b3493f058f10ce32365972c554572ff821e175dbc6f8ff6924f"},
635 | {file = "cffi-1.14.0-cp36-cp36m-win_amd64.whl", hash = "sha256:337d448e5a725bba2d8293c48d9353fc68d0e9e4088d62a9571def317797522b"},
636 | {file = "cffi-1.14.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e577934fc5f8779c554639376beeaa5657d54349096ef24abe8c74c5d9c117c3"},
637 | {file = "cffi-1.14.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:62ae9af2d069ea2698bf536dcfe1e4eed9090211dbaafeeedf5cb6c41b352f66"},
638 | {file = "cffi-1.14.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:14491a910663bf9f13ddf2bc8f60562d6bc5315c1f09c704937ef17293fb85b0"},
639 | {file = "cffi-1.14.0-cp37-cp37m-win32.whl", hash = "sha256:c43866529f2f06fe0edc6246eb4faa34f03fe88b64a0a9a942561c8e22f4b71f"},
640 | {file = "cffi-1.14.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2089ed025da3919d2e75a4d963d008330c96751127dd6f73c8dc0c65041b4c26"},
641 | {file = "cffi-1.14.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3b911c2dbd4f423b4c4fcca138cadde747abdb20d196c4a48708b8a2d32b16dd"},
642 | {file = "cffi-1.14.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:7e63cbcf2429a8dbfe48dcc2322d5f2220b77b2e17b7ba023d6166d84655da55"},
643 | {file = "cffi-1.14.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:3d311bcc4a41408cf5854f06ef2c5cab88f9fded37a3b95936c9879c1640d4c2"},
644 | {file = "cffi-1.14.0-cp38-cp38-win32.whl", hash = "sha256:675686925a9fb403edba0114db74e741d8181683dcf216be697d208857e04ca8"},
645 | {file = "cffi-1.14.0-cp38-cp38-win_amd64.whl", hash = "sha256:00789914be39dffba161cfc5be31b55775de5ba2235fe49aa28c148236c4e06b"},
646 | {file = "cffi-1.14.0.tar.gz", hash = "sha256:2d384f4a127a15ba701207f7639d94106693b6cd64173d6c8988e2c25f3ac2b6"},
647 | ]
648 | chardet = [
649 | {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"},
650 | {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"},
651 | ]
652 | click = [
653 | {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"},
654 | {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"},
655 | ]
656 | colorama = [
657 | {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"},
658 | {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"},
659 | ]
660 | cryptography = [
661 | {file = "cryptography-2.9.2-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:daf54a4b07d67ad437ff239c8a4080cfd1cc7213df57d33c97de7b4738048d5e"},
662 | {file = "cryptography-2.9.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:3b3eba865ea2754738616f87292b7f29448aec342a7c720956f8083d252bf28b"},
663 | {file = "cryptography-2.9.2-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:c447cf087cf2dbddc1add6987bbe2f767ed5317adb2d08af940db517dd704365"},
664 | {file = "cryptography-2.9.2-cp27-cp27m-win32.whl", hash = "sha256:f118a95c7480f5be0df8afeb9a11bd199aa20afab7a96bcf20409b411a3a85f0"},
665 | {file = "cryptography-2.9.2-cp27-cp27m-win_amd64.whl", hash = "sha256:c4fd17d92e9d55b84707f4fd09992081ba872d1a0c610c109c18e062e06a2e55"},
666 | {file = "cryptography-2.9.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d0d5aeaedd29be304848f1c5059074a740fa9f6f26b84c5b63e8b29e73dfc270"},
667 | {file = "cryptography-2.9.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:1e4014639d3d73fbc5ceff206049c5a9a849cefd106a49fa7aaaa25cc0ce35cf"},
668 | {file = "cryptography-2.9.2-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:96c080ae7118c10fcbe6229ab43eb8b090fccd31a09ef55f83f690d1ef619a1d"},
669 | {file = "cryptography-2.9.2-cp35-abi3-manylinux1_x86_64.whl", hash = "sha256:e993468c859d084d5579e2ebee101de8f5a27ce8e2159959b6673b418fd8c785"},
670 | {file = "cryptography-2.9.2-cp35-abi3-manylinux2010_x86_64.whl", hash = "sha256:88c881dd5a147e08d1bdcf2315c04972381d026cdb803325c03fe2b4a8ed858b"},
671 | {file = "cryptography-2.9.2-cp35-cp35m-win32.whl", hash = "sha256:651448cd2e3a6bc2bb76c3663785133c40d5e1a8c1a9c5429e4354201c6024ae"},
672 | {file = "cryptography-2.9.2-cp35-cp35m-win_amd64.whl", hash = "sha256:726086c17f94747cedbee6efa77e99ae170caebeb1116353c6cf0ab67ea6829b"},
673 | {file = "cryptography-2.9.2-cp36-cp36m-win32.whl", hash = "sha256:091d31c42f444c6f519485ed528d8b451d1a0c7bf30e8ca583a0cac44b8a0df6"},
674 | {file = "cryptography-2.9.2-cp36-cp36m-win_amd64.whl", hash = "sha256:bb1f0281887d89617b4c68e8db9a2c42b9efebf2702a3c5bf70599421a8623e3"},
675 | {file = "cryptography-2.9.2-cp37-cp37m-win32.whl", hash = "sha256:18452582a3c85b96014b45686af264563e3e5d99d226589f057ace56196ec78b"},
676 | {file = "cryptography-2.9.2-cp37-cp37m-win_amd64.whl", hash = "sha256:22e91636a51170df0ae4dcbd250d318fd28c9f491c4e50b625a49964b24fe46e"},
677 | {file = "cryptography-2.9.2-cp38-cp38-win32.whl", hash = "sha256:844a76bc04472e5135b909da6aed84360f522ff5dfa47f93e3dd2a0b84a89fa0"},
678 | {file = "cryptography-2.9.2-cp38-cp38-win_amd64.whl", hash = "sha256:1dfa985f62b137909496e7fc182dac687206d8d089dd03eaeb28ae16eec8e7d5"},
679 | {file = "cryptography-2.9.2.tar.gz", hash = "sha256:a0c30272fb4ddda5f5ffc1089d7405b7a71b0b0f51993cb4e5dbb4590b2fc229"},
680 | ]
681 | deepmerge = [
682 | {file = "deepmerge-0.1.0-py2.py3-none-any.whl", hash = "sha256:ae23dd76d3c0d22d33a3fd3980c92d3f0773e4affb48d9b341847d0b0a24e8f8"},
683 | {file = "deepmerge-0.1.0.tar.gz", hash = "sha256:3d37f739e74e8a284ee0bd683daaef88acc8438ba048545aefb87ade695a2a34"},
684 | ]
685 | flake8 = [
686 | {file = "flake8-3.8.1-py2.py3-none-any.whl", hash = "sha256:6c1193b0c3f853ef763969238f6c81e9e63ace9d024518edc020d5f1d6d93195"},
687 | {file = "flake8-3.8.1.tar.gz", hash = "sha256:ea6623797bf9a52f4c9577d780da0bb17d65f870213f7b5bcc9fca82540c31d5"},
688 | ]
689 | idna = [
690 | {file = "idna-2.9-py2.py3-none-any.whl", hash = "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"},
691 | {file = "idna-2.9.tar.gz", hash = "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb"},
692 | ]
693 | importlib-metadata = [
694 | {file = "importlib_metadata-1.6.0-py2.py3-none-any.whl", hash = "sha256:2a688cbaa90e0cc587f1df48bdc97a6eadccdcd9c35fb3f976a09e3b5016d90f"},
695 | {file = "importlib_metadata-1.6.0.tar.gz", hash = "sha256:34513a8a0c4962bc66d35b359558fd8a5e10cd472d37aec5f66858addef32c1e"},
696 | ]
697 | inflection = [
698 | {file = "inflection-0.4.0-py2.py3-none-any.whl", hash = "sha256:9a15d3598f01220e93f2207c432cfede50daff53137ce660fb8be838ef1ca6cc"},
699 | {file = "inflection-0.4.0.tar.gz", hash = "sha256:32a5c3341d9583ec319548b9015b7fbdf8c429cbcb575d326c33ae3a0e90d52c"},
700 | ]
701 | isort = [
702 | {file = "isort-4.3.21-py2.py3-none-any.whl", hash = "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"},
703 | {file = "isort-4.3.21.tar.gz", hash = "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1"},
704 | ]
705 | lazy-object-proxy = [
706 | {file = "lazy-object-proxy-1.4.3.tar.gz", hash = "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0"},
707 | {file = "lazy_object_proxy-1.4.3-cp27-cp27m-macosx_10_13_x86_64.whl", hash = "sha256:a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442"},
708 | {file = "lazy_object_proxy-1.4.3-cp27-cp27m-win32.whl", hash = "sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4"},
709 | {file = "lazy_object_proxy-1.4.3-cp27-cp27m-win_amd64.whl", hash = "sha256:4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a"},
710 | {file = "lazy_object_proxy-1.4.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d"},
711 | {file = "lazy_object_proxy-1.4.3-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a"},
712 | {file = "lazy_object_proxy-1.4.3-cp34-cp34m-win32.whl", hash = "sha256:9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e"},
713 | {file = "lazy_object_proxy-1.4.3-cp34-cp34m-win_amd64.whl", hash = "sha256:eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357"},
714 | {file = "lazy_object_proxy-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50"},
715 | {file = "lazy_object_proxy-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db"},
716 | {file = "lazy_object_proxy-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449"},
717 | {file = "lazy_object_proxy-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156"},
718 | {file = "lazy_object_proxy-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531"},
719 | {file = "lazy_object_proxy-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb"},
720 | {file = "lazy_object_proxy-1.4.3-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08"},
721 | {file = "lazy_object_proxy-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383"},
722 | {file = "lazy_object_proxy-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142"},
723 | {file = "lazy_object_proxy-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea"},
724 | {file = "lazy_object_proxy-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62"},
725 | {file = "lazy_object_proxy-1.4.3-cp38-cp38-win32.whl", hash = "sha256:5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd"},
726 | {file = "lazy_object_proxy-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239"},
727 | ]
728 | marshmallow = [
729 | {file = "marshmallow-3.5.1-py2.py3-none-any.whl", hash = "sha256:ac2e13b30165501b7d41fc0371b8df35944f5849769d136f20e2c5f6cdc6e665"},
730 | {file = "marshmallow-3.5.1.tar.gz", hash = "sha256:90854221bbb1498d003a0c3cc9d8390259137551917961c8b5258c64026b2f85"},
731 | ]
732 | marshmallow-dataclass = [
733 | {file = "marshmallow_dataclass-7.5.0.tar.gz", hash = "sha256:1bf04647541ab7c5fd4184984391c56b1613ff7e0ef9dbd63acfb5f509f43951"},
734 | ]
735 | marshmallow-enum = [
736 | {file = "marshmallow-enum-1.5.1.tar.gz", hash = "sha256:38e697e11f45a8e64b4a1e664000897c659b60aa57bfa18d44e226a9920b6e58"},
737 | {file = "marshmallow_enum-1.5.1-py2.py3-none-any.whl", hash = "sha256:57161ab3dbfde4f57adeb12090f39592e992b9c86d206d02f6bd03ebec60f072"},
738 | ]
739 | mccabe = [
740 | {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"},
741 | {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"},
742 | ]
743 | more-itertools = [
744 | {file = "more-itertools-8.2.0.tar.gz", hash = "sha256:b1ddb932186d8a6ac451e1d95844b382f55e12686d51ca0c68b6f61f2ab7a507"},
745 | {file = "more_itertools-8.2.0-py3-none-any.whl", hash = "sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c"},
746 | ]
747 | mypy-extensions = [
748 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
749 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
750 | ]
751 | packaging = [
752 | {file = "packaging-20.3-py2.py3-none-any.whl", hash = "sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752"},
753 | {file = "packaging-20.3.tar.gz", hash = "sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3"},
754 | ]
755 | paho-mqtt = [
756 | {file = "paho-mqtt-1.5.0.tar.gz", hash = "sha256:e3d286198baaea195c8b3bc221941d25a3ab0e1507fc1779bdb7473806394be4"},
757 | ]
758 | pathspec = [
759 | {file = "pathspec-0.8.0-py2.py3-none-any.whl", hash = "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0"},
760 | {file = "pathspec-0.8.0.tar.gz", hash = "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"},
761 | ]
762 | pluggy = [
763 | {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"},
764 | {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"},
765 | ]
766 | py = [
767 | {file = "py-1.8.1-py2.py3-none-any.whl", hash = "sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0"},
768 | {file = "py-1.8.1.tar.gz", hash = "sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa"},
769 | ]
770 | pycodestyle = [
771 | {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"},
772 | {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"},
773 | ]
774 | pycparser = [
775 | {file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"},
776 | {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"},
777 | ]
778 | pyflakes = [
779 | {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"},
780 | {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"},
781 | ]
782 | pylint = [
783 | {file = "pylint-2.4.4-py3-none-any.whl", hash = "sha256:886e6afc935ea2590b462664b161ca9a5e40168ea99e5300935f6591ad467df4"},
784 | {file = "pylint-2.4.4.tar.gz", hash = "sha256:3db5468ad013380e987410a8d6956226963aed94ecb5f9d3a28acca6d9ac36cd"},
785 | ]
786 | pyopenssl = [
787 | {file = "pyOpenSSL-19.1.0-py2.py3-none-any.whl", hash = "sha256:621880965a720b8ece2f1b2f54ea2071966ab00e2970ad2ce11d596102063504"},
788 | {file = "pyOpenSSL-19.1.0.tar.gz", hash = "sha256:9a24494b2602aaf402be5c9e30a0b82d4a5c67528fe8fb475e3f3bc00dd69507"},
789 | ]
790 | pyparsing = [
791 | {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"},
792 | {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
793 | ]
794 | pytest = [
795 | {file = "pytest-5.4.1-py3-none-any.whl", hash = "sha256:0e5b30f5cb04e887b91b1ee519fa3d89049595f428c1db76e73bd7f17b09b172"},
796 | {file = "pytest-5.4.1.tar.gz", hash = "sha256:84dde37075b8805f3d1f392cc47e38a0e59518fb46a431cfdaf7cf1ce805f970"},
797 | ]
798 | regex = [
799 | {file = "regex-2020.5.14-cp27-cp27m-win32.whl", hash = "sha256:e565569fc28e3ba3e475ec344d87ed3cd8ba2d575335359749298a0899fe122e"},
800 | {file = "regex-2020.5.14-cp27-cp27m-win_amd64.whl", hash = "sha256:d466967ac8e45244b9dfe302bbe5e3337f8dc4dec8d7d10f5e950d83b140d33a"},
801 | {file = "regex-2020.5.14-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:27ff7325b297fb6e5ebb70d10437592433601c423f5acf86e5bc1ee2919b9561"},
802 | {file = "regex-2020.5.14-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ea55b80eb0d1c3f1d8d784264a6764f931e172480a2f1868f2536444c5f01e01"},
803 | {file = "regex-2020.5.14-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:c9bce6e006fbe771a02bda468ec40ffccbf954803b470a0345ad39c603402577"},
804 | {file = "regex-2020.5.14-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:d881c2e657c51d89f02ae4c21d9adbef76b8325fe4d5cf0e9ad62f850f3a98fd"},
805 | {file = "regex-2020.5.14-cp36-cp36m-win32.whl", hash = "sha256:99568f00f7bf820c620f01721485cad230f3fb28f57d8fbf4a7967ec2e446994"},
806 | {file = "regex-2020.5.14-cp36-cp36m-win_amd64.whl", hash = "sha256:70c14743320a68c5dac7fc5a0f685be63bc2024b062fe2aaccc4acc3d01b14a1"},
807 | {file = "regex-2020.5.14-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:a7c37f048ec3920783abab99f8f4036561a174f1314302ccfa4e9ad31cb00eb4"},
808 | {file = "regex-2020.5.14-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:89d76ce33d3266173f5be80bd4efcbd5196cafc34100fdab814f9b228dee0fa4"},
809 | {file = "regex-2020.5.14-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:51f17abbe973c7673a61863516bdc9c0ef467407a940f39501e786a07406699c"},
810 | {file = "regex-2020.5.14-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:ce5cc53aa9fbbf6712e92c7cf268274eaff30f6bd12a0754e8133d85a8fb0f5f"},
811 | {file = "regex-2020.5.14-cp37-cp37m-win32.whl", hash = "sha256:8044d1c085d49673aadb3d7dc20ef5cb5b030c7a4fa253a593dda2eab3059929"},
812 | {file = "regex-2020.5.14-cp37-cp37m-win_amd64.whl", hash = "sha256:c2062c7d470751b648f1cacc3f54460aebfc261285f14bc6da49c6943bd48bdd"},
813 | {file = "regex-2020.5.14-cp38-cp38-manylinux1_i686.whl", hash = "sha256:329ba35d711e3428db6b45a53b1b13a0a8ba07cbbcf10bbed291a7da45f106c3"},
814 | {file = "regex-2020.5.14-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:579ea215c81d18da550b62ff97ee187b99f1b135fd894a13451e00986a080cad"},
815 | {file = "regex-2020.5.14-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:3a9394197664e35566242686d84dfd264c07b20f93514e2e09d3c2b3ffdf78fe"},
816 | {file = "regex-2020.5.14-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:ce367d21f33e23a84fb83a641b3834dd7dd8e9318ad8ff677fbfae5915a239f7"},
817 | {file = "regex-2020.5.14-cp38-cp38-win32.whl", hash = "sha256:1386e75c9d1574f6aa2e4eb5355374c8e55f9aac97e224a8a5a6abded0f9c927"},
818 | {file = "regex-2020.5.14-cp38-cp38-win_amd64.whl", hash = "sha256:7e61be8a2900897803c293247ef87366d5df86bf701083b6c43119c7c6c99108"},
819 | {file = "regex-2020.5.14.tar.gz", hash = "sha256:ce450ffbfec93821ab1fea94779a8440e10cf63819be6e176eb1973a6017aff5"},
820 | ]
821 | requests = [
822 | {file = "requests-2.23.0-py2.py3-none-any.whl", hash = "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee"},
823 | {file = "requests-2.23.0.tar.gz", hash = "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"},
824 | ]
825 | rope = [
826 | {file = "rope-0.17.0.tar.gz", hash = "sha256:658ad6705f43dcf3d6df379da9486529cf30e02d9ea14c5682aa80eb33b649e1"},
827 | ]
828 | six = [
829 | {file = "six-1.14.0-py2.py3-none-any.whl", hash = "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"},
830 | {file = "six-1.14.0.tar.gz", hash = "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a"},
831 | ]
832 | toml = [
833 | {file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"},
834 | {file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"},
835 | ]
836 | typed-ast = [
837 | {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"},
838 | {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"},
839 | {file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"},
840 | {file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"},
841 | {file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"},
842 | {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"},
843 | {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"},
844 | {file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"},
845 | {file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"},
846 | {file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"},
847 | {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"},
848 | {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"},
849 | {file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"},
850 | {file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"},
851 | {file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"},
852 | {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"},
853 | {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"},
854 | {file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"},
855 | {file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"},
856 | {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"},
857 | {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"},
858 | ]
859 | typing-extensions = [
860 | {file = "typing_extensions-3.7.4.2-py2-none-any.whl", hash = "sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392"},
861 | {file = "typing_extensions-3.7.4.2-py3-none-any.whl", hash = "sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5"},
862 | {file = "typing_extensions-3.7.4.2.tar.gz", hash = "sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae"},
863 | ]
864 | typing-inspect = [
865 | {file = "typing_inspect-0.5.0-py2-none-any.whl", hash = "sha256:75c97b7854426a129f3184c68588db29091ff58e6908ed520add1d52fc44df6e"},
866 | {file = "typing_inspect-0.5.0-py3-none-any.whl", hash = "sha256:c6ed1cd34860857c53c146a6704a96da12e1661087828ce350f34addc6e5eee3"},
867 | {file = "typing_inspect-0.5.0.tar.gz", hash = "sha256:811b44f92e780b90cfe7bac94249a4fae87cfaa9b40312765489255045231d9c"},
868 | ]
869 | uplink = [
870 | {file = "uplink-0.9.1-py2.py3-none-any.whl", hash = "sha256:b01b35cb1174d4006d901210b15700447ac2c576b4063fdd3b45f4e7c271c759"},
871 | {file = "uplink-0.9.1.tar.gz", hash = "sha256:17355ed8219078bfa9c2b0419f757a9010f7b71f91a8ca1f460b5cd206266ba1"},
872 | ]
873 | uritemplate = [
874 | {file = "uritemplate-3.0.1-py2.py3-none-any.whl", hash = "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f"},
875 | {file = "uritemplate-3.0.1.tar.gz", hash = "sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae"},
876 | ]
877 | urllib3 = [
878 | {file = "urllib3-1.25.8-py2.py3-none-any.whl", hash = "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc"},
879 | {file = "urllib3-1.25.8.tar.gz", hash = "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc"},
880 | ]
881 | wcwidth = [
882 | {file = "wcwidth-0.1.9-py2.py3-none-any.whl", hash = "sha256:cafe2186b3c009a04067022ce1dcd79cb38d8d65ee4f4791b8888d6599d1bbe1"},
883 | {file = "wcwidth-0.1.9.tar.gz", hash = "sha256:ee73862862a156bf77ff92b09034fc4825dd3af9cf81bc5b360668d425f3c5f1"},
884 | ]
885 | wrapt = [
886 | {file = "wrapt-1.11.2.tar.gz", hash = "sha256:565a021fd19419476b9362b05eeaa094178de64f8361e44468f9e9d7843901e1"},
887 | ]
888 | zipp = [
889 | {file = "zipp-3.1.0-py3-none-any.whl", hash = "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b"},
890 | {file = "zipp-3.1.0.tar.gz", hash = "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"},
891 | ]
892 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "thinq2"
3 | version = "0.1.0"
4 | description = ""
5 | authors = ["tinkerborg "]
6 |
7 | [tool.poetry.dependencies]
8 | python = "^3.7"
9 | uplink = "^0.9.1"
10 | marshmallow = "^3.5.1"
11 | marshmallow-dataclass = "^7.5.0"
12 | inflection = "^0.4.0"
13 | marshmallow_enum = "^1.5.1"
14 | PyOpenSSL = "^19.1.0"
15 | paho-mqtt = "^1.5.0"
16 | attrdict = "^2.0.1"
17 | deepmerge = "^0.1.0"
18 |
19 | [tool.poetry.dev-dependencies]
20 | pytest = "^5.2"
21 | pylint = "^2.4.4"
22 | black = "^19.10b0"
23 | flake8 = "^3.8.1"
24 | rope = "^0.17.0"
25 |
26 | [build-system]
27 | requires = ["poetry>=0.12"]
28 | build-backend = "poetry.masonry.api"
29 |
--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | junit_family = xunit1
3 |
4 | filterwarnings =
5 | # deprecation warning from attrdict dependency
6 | ignore:Using or importing the ABCs:DeprecationWarning
7 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tinkerborg/thinq2-python/9775de9fa1a775533e15fb734c319949f9a48f05/tests/__init__.py
--------------------------------------------------------------------------------
/tests/test_schema.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from marshmallow_dataclass import dataclass
4 |
5 | from thinq2 import schema
6 |
7 |
8 | def test_base_schema_ignores_unknown_fields():
9 | model = schema.BaseSchema().load(dict(foo="bar"))
10 | assert model == {}
11 |
12 |
13 | def test_controller_can_be_constructed_from_dict(Controller, valid_data):
14 | controller = Controller(valid_data)
15 | assert isinstance(controller, Controller)
16 |
17 |
18 | def test_controller_can_be_constructed_from_kwargs(Controller, valid_data):
19 | controller = Controller(**valid_data)
20 | assert isinstance(controller, Controller)
21 |
22 |
23 | def test_controller_can_be_constructed_from_model(Controller, Model, valid_data):
24 | model = Model(**valid_data)
25 | controller = Controller(model)
26 | assert isinstance(controller, Controller)
27 |
28 |
29 | def test_controller_as_dict_equals_model_data(Controller, valid_data):
30 | controller = Controller(valid_data)
31 | assert vars(controller) == valid_data
32 |
33 |
34 | def test_controller_constructor_styles_are_equivalent(Controller, Model, valid_data):
35 | controller_from_dict = Controller(valid_data)
36 | controller_from_kwargs = Controller(**valid_data)
37 | controller_from_model = Controller(Model(**valid_data))
38 | assert vars(controller_from_dict) == vars(controller_from_kwargs)
39 | assert vars(controller_from_dict) == vars(controller_from_model)
40 |
41 |
42 | def test_controller_instances_with_identical_data_are_not_equal(Controller, valid_data):
43 | controller_a = Controller(valid_data)
44 | controller_b = Controller(valid_data)
45 | assert controller_a != controller_b
46 |
47 |
48 | def test_initialzer_decorator_sets_default_value(Controller, data_with_missing_quux):
49 | quux = 43
50 |
51 | class QuuxedController(Controller):
52 | @schema.initializer
53 | def quux(self):
54 | return quux
55 |
56 | controller = QuuxedController(**data_with_missing_quux)
57 | assert controller.quux == quux
58 |
59 |
60 | def test_initialzer_ignored_when_value_supplied(Controller, data_with_missing_quux):
61 | quux = 43
62 | data = {**data_with_missing_quux, **dict(quux=quux + 1)}
63 |
64 | class QuuxedController(Controller):
65 | @schema.initializer
66 | def quux(self):
67 | return quux
68 |
69 | controller = QuuxedController(data)
70 | assert controller.quux != quux
71 |
72 |
73 | def test_initialize_nested_controller(ParentController, Controller, nested_data):
74 | controller = ParentController(nested_data)
75 | assert isinstance(controller.child, Controller)
76 |
77 |
78 | def test_nested_controller_as_dict_matches_data(ParentController, nested_data):
79 | controller = ParentController(nested_data)
80 | assert nested_data == vars(controller)
81 |
82 |
83 | @pytest.fixture
84 | def Model():
85 | @dataclass
86 | class Model:
87 | foo: str
88 | quux: int
89 |
90 | return Model
91 |
92 |
93 | @pytest.fixture
94 | def Controller(Model):
95 | @schema.controller(Model)
96 | class Controller:
97 | pass
98 |
99 | return Controller
100 |
101 |
102 | @pytest.fixture
103 | def ParentModel(Model):
104 | @dataclass
105 | class ParentModel:
106 | baz: str
107 | child: Model
108 |
109 | return ParentModel
110 |
111 |
112 | @pytest.fixture
113 | def ParentController(ParentModel, Controller):
114 | @schema.controller(ParentModel)
115 | class ParentController:
116 | @schema.controller
117 | def child(self, child):
118 | return Controller(child)
119 |
120 | return ParentController
121 |
122 |
123 | @pytest.fixture
124 | def valid_data():
125 | return dict(foo="bar", quux=42)
126 |
127 |
128 | @pytest.fixture
129 | def data_with_missing_quux():
130 | return dict(foo="bar")
131 |
132 |
133 | @pytest.fixture
134 | def nested_data(valid_data):
135 | return dict(baz="xyzzy", child=valid_data)
136 |
--------------------------------------------------------------------------------
/thinq2/__init__.py:
--------------------------------------------------------------------------------
1 | __version__ = "0.1.0"
2 |
3 |
4 | API_KEY = "VGhpblEyLjAgU0VSVklDRQ=="
5 | SERVICE_CODE = "SVC202"
6 | SERVICE_PHASE = "OP"
7 | APP_LEVEL = "PRD"
8 | APP_OS = "ANDROID"
9 | APP_TYPE = "NUTS"
10 | APP_VERSION = "3.0.1700"
11 | DIVISION = "ha"
12 | OAUTH_REDIRECT_URI = "https://kr.m.lgaccount.com/login/iabClose"
13 | OAUTH_TIMESTAMP_FORMAT = "%a, %d %b %Y %H:%M:%S +0000"
14 | OAUTH_SECRET = "c053c2a6ddeb7ad97cb0eed0dcb31cf8"
15 | LGE_APP_KEY = "LGAO221A02"
16 | THIRD_PARTY_LOGINS = "GGL,AMZ,FBK"
17 |
18 | AWS_IOTT_CA_CERT_URL = "https://www.websecurity.digicert.com/content/dam/websitesecurity/digitalassets/desktop/pdfs/roots/VeriSign-Class%203-Public-Primary-Certification-Authority-G5.pem"
19 | AWS_IOTT_ALPN_PROTOCOL = "x-amzn-mqtt-ca"
20 |
--------------------------------------------------------------------------------
/thinq2/client/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tinkerborg/thinq2-python/9775de9fa1a775533e15fb734c319949f9a48f05/thinq2/client/__init__.py
--------------------------------------------------------------------------------
/thinq2/client/base.py:
--------------------------------------------------------------------------------
1 | from uplink import Consumer
2 |
3 | from thinq2.util import end_with
4 |
5 |
6 | class BaseClient(Consumer):
7 | """ Base client class """
8 |
9 | def __init__(self, base_url=None, headers={}, **kwargs):
10 | super().__init__(end_with(base_url or self.base_url, "/"), **kwargs)
11 | self.session.headers.update(headers)
12 |
--------------------------------------------------------------------------------
/thinq2/client/common.py:
--------------------------------------------------------------------------------
1 | from uplink import get
2 |
3 | from thinq2.client.base import BaseClient
4 | from thinq2.model.common import Route
5 | from thinq2.model.thinq import ThinQResult
6 |
7 |
8 | class CommonClient(BaseClient):
9 | """LG ThinQ Common API client"""
10 |
11 | base_url = "https://common.lgthinq.com/"
12 |
13 | @get("route")
14 | def get_route(self) -> ThinQResult(Route):
15 | """Retrieves route definition for current country/language (MQTT url, etc)"""
16 |
--------------------------------------------------------------------------------
/thinq2/client/gateway.py:
--------------------------------------------------------------------------------
1 | from uplink import get
2 |
3 | from thinq2.client.base import BaseClient
4 | from thinq2.model.gateway import Gateway
5 | from thinq2.model.thinq import ThinQResult
6 |
7 |
8 | class GatewayClient(BaseClient):
9 | """LG ThinQ Gateway API client"""
10 |
11 | base_url = "https://route.lgthinq.com:46030/v1/"
12 |
13 | @get("service/application/gateway-uri")
14 | def get_gateway(self) -> ThinQResult(Gateway):
15 | """Retrieves Gateway information for current country/language"""
16 |
--------------------------------------------------------------------------------
/thinq2/client/oauth.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import hmac
3 | import base64
4 | import hashlib
5 |
6 | from urllib.parse import urlencode
7 |
8 | from uplink import Consumer, Field
9 | from uplink import headers, form_url_encoded, get, post, response_handler
10 | from uplink.arguments import Header
11 | from uplink.decorators import inject
12 | from uplink.hooks import RequestAuditor
13 |
14 | import thinq2
15 | from thinq2.model.auth import OAuthToken, UserProfile
16 |
17 | REDIRECT_URI = "https://kr.m.lgaccount.com/login/iabClose"
18 |
19 |
20 | def lg_oauth_signer(request_builder):
21 | url = "/{}".format(request_builder.relative_url)
22 |
23 | if request_builder.info["data"]:
24 | # LG expects the form vars to be sorted alphabetically before signing
25 | form = urlencode(sorted(request_builder.info["data"].items()))
26 | url = "{}?{}".format(url, form)
27 |
28 | timestamp = datetime.datetime.utcnow().strftime(thinq2.OAUTH_TIMESTAMP_FORMAT)
29 | message = "{}\n{}".format(url, timestamp).encode("utf8")
30 | secret = thinq2.OAUTH_SECRET.encode("utf8")
31 | digest = hmac.new(secret, message, hashlib.sha1).digest()
32 | signature = base64.b64encode(digest)
33 |
34 | request_builder.info["headers"].update(
35 | {
36 | "x-lge-oauth-signature": signature,
37 | "x-lge-oauth-date": timestamp,
38 | "x-lge-appkey": thinq2.LGE_APP_KEY,
39 | }
40 | )
41 |
42 |
43 | class BearerToken(Header):
44 | def _modify_request(self, request_builder, value):
45 | """Updates request header contents."""
46 | request_builder.info["headers"]["Authorization"] = "Bearer {}".format(value)
47 |
48 |
49 | @inject(RequestAuditor(lg_oauth_signer))
50 | @headers({"Accept": "application/json"})
51 | class OAuthClient(Consumer):
52 | """LG ThinQ OAuth Client"""
53 |
54 | auth = {}
55 |
56 | @form_url_encoded
57 | @post("oauth/1.0/oauth2/token")
58 | def get_token(
59 | self,
60 | code: Field,
61 | grant_type: Field = "authorization_code",
62 | redirect_uri: Field = REDIRECT_URI,
63 | ) -> OAuthToken.Schema():
64 |
65 | """Retrieves initial OAuth token from authorization code"""
66 |
67 | @form_url_encoded
68 | @post("oauth/1.0/oauth2/token")
69 | def refresh_token(
70 | self, refresh_token: Field, grant_type: Field = "refresh_token"
71 | ) -> OAuthToken.Schema():
72 |
73 | """Retrieves updated OAuth token from refresh token"""
74 |
75 | @response_handler(lambda response: response.json().get("account"))
76 | @get("oauth/1.0/users/profile")
77 | def get_profile(self, access_code: BearerToken) -> UserProfile.Schema:
78 |
79 | """Retrieves current user's OAuth profile"""
80 |
--------------------------------------------------------------------------------
/thinq2/client/objectstore.py:
--------------------------------------------------------------------------------
1 | from uplink import Url, get, returns
2 |
3 | from thinq2.client.base import BaseClient
4 |
5 |
6 | class ObjectStoreClient(BaseClient):
7 | """LG ThinQ API client"""
8 |
9 | base_url = "https://objectstore.lgthinq.com"
10 |
11 | @returns.json
12 | @get
13 | def get_json_url(self, url: Url):
14 | """Retrieves an arbitrary JSON object"""
15 |
--------------------------------------------------------------------------------
/thinq2/client/thinq.py:
--------------------------------------------------------------------------------
1 | from uplink import Field, Path, Query
2 | from uplink import get, post, delete, json
3 |
4 | from thinq2.client.base import BaseClient
5 | from thinq2.model.thinq import (
6 | DeviceCollection,
7 | DeviceDescriptor,
8 | ThinQResult,
9 | ThinQResultSuccess,
10 | IOTRegistration,
11 | ModelJsonDescriptor,
12 | )
13 |
14 |
15 | class ThinQClient(BaseClient):
16 | """LG ThinQ API client"""
17 |
18 | @get("service/application/dashboard")
19 | def get_devices(self) -> ThinQResult(DeviceCollection):
20 | """Retrieves collection of user's registered devices with dashboard data."""
21 |
22 | @get("service/devices/{device_id}")
23 | def get_device(self, device_id: Path) -> ThinQResult(DeviceDescriptor):
24 | """Retrieves an individual device"""
25 |
26 | @get("service/application/modeljson")
27 | def get_model_json_descriptor(
28 | self, device_id: Query("deviceId"), model_name: Query("modelName")
29 | ) -> ThinQResult(ModelJsonDescriptor):
30 | """Retrieves ModelJson descriptor for a device"""
31 |
32 | @get("service/users/client")
33 | def get_registered(self) -> ThinQResultSuccess():
34 | """Get client registration status"""
35 |
36 | @post("service/users/client")
37 | def register(self) -> ThinQResultSuccess():
38 | """Register client ID"""
39 |
40 | @delete("service/users/client")
41 | def deregister(self) -> ThinQResultSuccess():
42 | """Deregister client ID"""
43 |
44 | @json
45 | @post("service/users/client/certificate")
46 | def register_iot(self, csr: Field) -> ThinQResult(IOTRegistration):
47 | """Register an IoT/MQTT session, given a csr"""
48 |
--------------------------------------------------------------------------------
/thinq2/controller/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tinkerborg/thinq2-python/9775de9fa1a775533e15fb734c319949f9a48f05/thinq2/controller/__init__.py
--------------------------------------------------------------------------------
/thinq2/controller/auth.py:
--------------------------------------------------------------------------------
1 | import secrets
2 | import base64
3 | import uuid
4 | import re
5 | import thinq2
6 |
7 | from urllib.parse import urlencode, urljoin, urlparse, parse_qs
8 |
9 | from uplink.clients.io import RequestTemplate, transitions
10 | from thinq2.client.oauth import OAuthClient
11 | from thinq2.client.gateway import GatewayClient
12 | from thinq2.model.auth import ThinQSession
13 | from thinq2.schema import controller, initializer
14 |
15 |
16 | @controller(ThinQSession)
17 | class ThinQAuth(RequestTemplate):
18 | def __call__(self, request_builder):
19 | self._ret = request_builder.return_type
20 | request_builder.add_request_template(self)
21 |
22 | def before_request(self, request):
23 | self.add_headers(*request)
24 |
25 | def add_headers(self, method, url, extras):
26 | extras["headers"].update(self.base_headers)
27 | extras["headers"].update(self.auth_headers)
28 |
29 | def after_response(self, request, response):
30 | if response.status_code == 400:
31 | # XXX - thinq auth error - find a cleaner way of handling this
32 | # this gets raised when the oauth code is expired/invalid
33 | # XXX - this should also die on repeated 400s for the same request
34 | # (after token refresh)
35 | try:
36 | content = response.json()
37 | if content.get("resultCode") != "0102":
38 | return
39 | except ValueError:
40 | pass
41 |
42 | self.refresh_token()
43 | self.add_headers(*request)
44 | return transitions.sleep(1)
45 |
46 | # XXX - this should throw exceptions if they fail
47 | def set_token(self, authorization_code):
48 | self.token = self.oauth_client.get_token(authorization_code)
49 |
50 | def set_token_from_url(self, url):
51 | params = parse_qs(urlparse(url).query)
52 | ## XXX - throw error if no code
53 | self.set_token(params["code"][0])
54 |
55 | def refresh_token(self):
56 | self.token.update(self.oauth_client.refresh_token(self.token.refresh_token))
57 |
58 | @property
59 | def auth_headers(self):
60 | return {
61 | "x-emp-token": self.token.access_token,
62 | "x-user-no": self.profile.user_no,
63 | }
64 |
65 | @property
66 | def base_headers(self):
67 | return {
68 | "x-client-id": self.client_id,
69 | "x-country-code": self.country_code,
70 | "x-language-code": self.language_code,
71 | "x-message-id": self.message_id,
72 | "x-api-key": thinq2.API_KEY,
73 | "x-service-code": thinq2.SERVICE_CODE,
74 | "x-service-phase": thinq2.SERVICE_PHASE,
75 | "x-thinq-app-level": thinq2.APP_LEVEL,
76 | "x-thinq-app-os": thinq2.APP_OS,
77 | "x-thinq-app-type": thinq2.APP_TYPE,
78 | "x-thinq-app-ver": thinq2.APP_VERSION,
79 | }
80 |
81 | @property
82 | def oauth_login_url(self):
83 | """ Returns a URL to start the OAuth flow """
84 |
85 | url = urljoin(self.gateway.emp_uri, "spx/login/signIn")
86 | query = urlencode(
87 | {
88 | "country": self.country_code,
89 | "language": self.language_code,
90 | "client_id": thinq2.LGE_APP_KEY,
91 | "svc_list": thinq2.SERVICE_CODE,
92 | "division": thinq2.DIVISION,
93 | "show_thirdparty_login": thinq2.THIRD_PARTY_LOGINS,
94 | "redirect_uri": thinq2.OAUTH_REDIRECT_URI,
95 | "state": uuid.uuid1().hex,
96 | }
97 | )
98 | return "{}?{}".format(url, query)
99 |
100 | @property
101 | def oauth_backend_url(self):
102 | return "https://{}.lgeapi.com".format(self.country_code)
103 |
104 | @property
105 | def oauth_client(self):
106 | return OAuthClient(base_url=self.oauth_backend_url)
107 |
108 | @property
109 | def gateway_client(self):
110 | return GatewayClient(
111 | # XXX technically if this was reused the messageid would not change
112 | headers=self.base_headers,
113 | client_id=self.client_id,
114 | country_code=self.country_code,
115 | language_code=self.language_code,
116 | )
117 |
118 | @property
119 | def message_id(self):
120 | id = base64.urlsafe_b64encode(uuid.uuid4().bytes).decode("UTF-8")
121 | return re.sub("=*$", "", id)
122 |
123 | @initializer
124 | def client_id(self):
125 | return secrets.token_hex(32)
126 |
127 | @initializer
128 | def gateway(self):
129 | return self.gateway_client.get_gateway()
130 |
131 | @initializer
132 | def profile(self):
133 | return self.oauth_client.get_profile(self.token.access_token)
134 |
--------------------------------------------------------------------------------
/thinq2/controller/device.py:
--------------------------------------------------------------------------------
1 | from deepmerge import Merger
2 |
3 | from thinq2.schema import controller
4 | from thinq2.util import memoize
5 | from thinq2.client.thinq import ThinQClient
6 | from thinq2.client.objectstore import ObjectStoreClient
7 | from thinq2.model.thinq import DeviceDescriptor, ModelJsonDataclass
8 |
9 |
10 | @controller(DeviceDescriptor)
11 | class ThinQDevice:
12 | def __init__(self, auth):
13 | self._auth = auth
14 | self._on_update = None
15 |
16 | def update(self, state):
17 | schema = self.snapshot.Schema()
18 | snapshot = schema.dump(self.snapshot)
19 | update = self._merger.merge(snapshot, state)
20 | self.snapshot = schema.load(update)
21 | if self._on_update:
22 | self._on_update(self)
23 |
24 | def on_update(self, func):
25 | self._on_update = func
26 |
27 | @property
28 | def _merger(self):
29 | return Merger([(dict, ["merge"])], ["override"], ["override"])
30 |
31 | @property
32 | def state(self):
33 | return self._model.Schema().load(self.snapshot.state)
34 |
35 | @property
36 | @memoize
37 | def model_json(self):
38 | return self._object_store_client.get_json_url(self.model_json_uri)
39 |
40 | @property
41 | @memoize
42 | def model_json_uri(self):
43 | descriptor = self._thinq_client.get_model_json_descriptor(
44 | device_id=self.device_id, model_name=self.model_name
45 | )
46 | return descriptor.model_json_uri
47 |
48 | @property
49 | @memoize
50 | def _model(self):
51 | return ModelJsonDataclass(self.model_json).build(self.alias)
52 |
53 | @property
54 | def _thinq_client(self):
55 | return ThinQClient(base_url=self._auth.gateway.thinq2_uri, auth=self._auth)
56 |
57 | @property
58 | def _object_store_client(self):
59 | return ObjectStoreClient()
60 |
--------------------------------------------------------------------------------
/thinq2/controller/mqtt.py:
--------------------------------------------------------------------------------
1 | import requests
2 | import ssl
3 | from urllib.parse import urlparse
4 |
5 | from OpenSSL import crypto
6 | from OpenSSL.SSL import FILETYPE_PEM
7 | from paho.mqtt.client import Client
8 |
9 | from thinq2.model.mqtt import MQTTConfiguration, MQTTMessage
10 | from thinq2.schema import controller, initializer
11 | from thinq2.client.thinq import ThinQClient
12 | from thinq2.client.common import CommonClient
13 | from thinq2.util import memoize
14 | from thinq2.util.filesystem import TempDir
15 |
16 | from thinq2 import AWS_IOTT_CA_CERT_URL, AWS_IOTT_ALPN_PROTOCOL
17 |
18 |
19 | @controller(MQTTConfiguration)
20 | class ThinQMQTT:
21 | def __init__(self, auth):
22 | self._auth = auth
23 |
24 | def connect(self):
25 | if not self.client.is_connected():
26 | endpoint = urlparse(self.route.mqtt_server)
27 | self.client.connect(endpoint.hostname, endpoint.port)
28 |
29 | def loop_start(self):
30 | self.connect()
31 | self.client.loop_start()
32 |
33 | def loop_forever(self):
34 | self.connect()
35 | self.client.loop_forever()
36 |
37 | def on_message(self, client, userdata, msg):
38 | self._on_message(client, userdata, msg)
39 |
40 | def on_connect(self, client, userdata, flags, rc):
41 | for topic in self.registration.subscriptions:
42 | client.subscribe(topic, 1)
43 |
44 | def on_device_message(self, message):
45 | pass
46 |
47 | def _on_message(self, client, userdata, msg):
48 | # XXX - nastiness
49 | message = None
50 | try:
51 | message = MQTTMessage.Schema().loads(msg.payload)
52 | except Exception as e:
53 | print("Can't parse MQTT message:", e)
54 | self.on_device_message(message)
55 |
56 | @property
57 | @memoize
58 | def client(self):
59 | client = Client(client_id=self._auth.client_id)
60 | client.tls_set_context(self.ssl_context)
61 | client.on_connect = self.on_connect
62 | client.on_message = self.on_message
63 | return client
64 |
65 | @property
66 | @memoize
67 | def thinq_client(self):
68 | return ThinQClient(base_url=self._auth.gateway.thinq2_uri, auth=self._auth)
69 |
70 | @property
71 | @memoize
72 | def common_client(self):
73 | return CommonClient(auth=self._auth)
74 |
75 | @property
76 | def ssl_context(self):
77 | temp_dir = TempDir()
78 | ca_cert_path = temp_dir.file(self.ca_cert)
79 | private_key_path = temp_dir.file(self.private_key)
80 | client_cert_path = temp_dir.file(self.registration.certificate_pem)
81 |
82 | context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
83 | context.set_alpn_protocols([AWS_IOTT_ALPN_PROTOCOL])
84 | context.load_verify_locations(cafile=ca_cert_path)
85 | context.load_cert_chain(certfile=client_cert_path, keyfile=private_key_path)
86 |
87 | return context
88 |
89 | @initializer
90 | def ca_cert(self):
91 | return requests.get(AWS_IOTT_CA_CERT_URL).text
92 |
93 | @initializer
94 | def private_key(self):
95 | key = crypto.PKey()
96 | key.generate_key(crypto.TYPE_RSA, 2048)
97 | return str(crypto.dump_privatekey(FILETYPE_PEM, key), "utf8")
98 |
99 | @initializer
100 | def csr(self):
101 | key = crypto.load_privatekey(FILETYPE_PEM, self.private_key)
102 | csr = crypto.X509Req()
103 | csr.get_subject().CN = "AWS IoT Certificate"
104 | csr.get_subject().O = "Amazon"
105 | csr.set_pubkey(key)
106 | csr.sign(key, "sha256")
107 | return str(crypto.dump_certificate_request(FILETYPE_PEM, csr), "utf8")
108 |
109 | @initializer
110 | def registration(self):
111 | if self.thinq_client.get_registered() is False:
112 | self.thinq_client.register()
113 | return self.thinq_client.register_iot(csr=self.csr)
114 |
115 | @initializer
116 | def route(self):
117 | return self.common_client.get_route()
118 |
--------------------------------------------------------------------------------
/thinq2/controller/thinq.py:
--------------------------------------------------------------------------------
1 | import gc
2 |
3 | from thinq2.schema import controller
4 | from thinq2.util import memoize
5 | from thinq2.client.thinq import ThinQClient
6 | from thinq2.controller.mqtt import ThinQMQTT
7 | from thinq2.controller.auth import ThinQAuth
8 | from thinq2.controller.device import ThinQDevice
9 | from thinq2.model.config import ThinQConfiguration
10 | from thinq2.model.mqtt import MQTTMessage
11 |
12 |
13 | @controller(ThinQConfiguration)
14 | class ThinQ:
15 |
16 | _devices = []
17 |
18 | def get_device(self, device_id):
19 | device = ThinQDevice(self.thinq_client.get_device(device_id), auth=self.auth)
20 | self._devices.append(device)
21 | return device
22 |
23 | # XXX - temporary?
24 | def start(self):
25 | self.mqtt.on_device_message = self._notify_device
26 | self.mqtt.loop_forever()
27 |
28 | def _notify_device(self, message: MQTTMessage):
29 | # XXX - ugly temporary PoC
30 | for device in self._devices:
31 | if len(gc.get_referrers(device)) <= 1:
32 | self._devices.remove(device)
33 | elif device.device_id == message.device_id:
34 | device.update(message.data.state.reported)
35 |
36 | @property
37 | @memoize
38 | def thinq_client(self):
39 | return ThinQClient(base_url=self.auth.gateway.thinq2_uri, auth=self.auth)
40 |
41 | @controller
42 | def auth(self, auth):
43 | return ThinQAuth(auth)
44 |
45 | @controller
46 | def mqtt(self, mqtt):
47 | return ThinQMQTT(mqtt, auth=self.auth)
48 |
--------------------------------------------------------------------------------
/thinq2/model/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tinkerborg/thinq2-python/9775de9fa1a775533e15fb734c319949f9a48f05/thinq2/model/__init__.py
--------------------------------------------------------------------------------
/thinq2/model/auth.py:
--------------------------------------------------------------------------------
1 | from marshmallow_dataclass import dataclass
2 |
3 | from thinq2.model.gateway import Gateway
4 | from thinq2.schema import CamelIDSchema
5 |
6 |
7 | @dataclass
8 | class OAuthToken:
9 | access_token: str
10 | expires_in: str
11 | oauth2_backend_url: str = None
12 | refresh_token: str = None
13 |
14 | def update(self, token: "OAuthToken"):
15 | self.access_token = token.access_token
16 | self.expires_in = token.expires_in
17 |
18 |
19 | @dataclass(base_schema=CamelIDSchema)
20 | class UserProfile:
21 | user_id: str
22 | user_no: str
23 |
24 |
25 | @dataclass
26 | class ThinQSession:
27 | country_code: str
28 | language_code: str
29 | client_id: str
30 | gateway: Gateway
31 | profile: UserProfile = None
32 | token: OAuthToken = None
33 |
--------------------------------------------------------------------------------
/thinq2/model/common.py:
--------------------------------------------------------------------------------
1 | from marshmallow_dataclass import dataclass
2 |
3 | from thinq2.schema import CamelCaseSchema
4 |
5 |
6 | @dataclass(base_schema=CamelCaseSchema)
7 | class Route:
8 | api_server: str
9 | mqtt_server: str
10 |
--------------------------------------------------------------------------------
/thinq2/model/config.py:
--------------------------------------------------------------------------------
1 | from marshmallow_dataclass import dataclass
2 |
3 | from thinq2.model.auth import ThinQSession
4 | from thinq2.model.mqtt import MQTTConfiguration
5 |
6 |
7 | @dataclass
8 | class ThinQConfiguration:
9 | auth: ThinQSession
10 | mqtt: MQTTConfiguration
11 |
--------------------------------------------------------------------------------
/thinq2/model/device/__init__.py:
--------------------------------------------------------------------------------
1 | from .base import Device
2 | from .laundry import LaundryDevice
3 |
4 | device_types = {
5 | 201: LaundryDevice,
6 | 202: LaundryDevice,
7 | }
8 |
--------------------------------------------------------------------------------
/thinq2/model/device/base.py:
--------------------------------------------------------------------------------
1 | from marshmallow_dataclass import dataclass
2 |
3 | from thinq2.schema import CamelCaseSchema
4 |
5 |
6 | @dataclass(base_schema=CamelCaseSchema)
7 | class DeviceStatic:
8 | device_type: int
9 | country_code: str
10 |
11 |
12 | @dataclass(base_schema=CamelCaseSchema)
13 | class Device:
14 | timestamp: float
15 | static: DeviceStatic
16 |
--------------------------------------------------------------------------------
/thinq2/model/device/laundry.py:
--------------------------------------------------------------------------------
1 | from dataclasses import field
2 | from marshmallow_dataclass import dataclass
3 |
4 | from thinq2.schema import CamelCaseSchema
5 |
6 | from .base import Device
7 |
8 |
9 | @dataclass(base_schema=CamelCaseSchema)
10 | class LaundryDevice(Device):
11 | state: dict = field(metadata=dict(data_key="washerDryer"))
12 |
--------------------------------------------------------------------------------
/thinq2/model/gateway.py:
--------------------------------------------------------------------------------
1 | import uuid
2 | import thinq2
3 | from urllib import parse
4 | from marshmallow_dataclass import dataclass
5 |
6 | from thinq2.schema import CamelCaseSchema
7 |
8 |
9 | @dataclass(base_schema=CamelCaseSchema)
10 | class Gateway:
11 | country_code: str
12 | language_code: str
13 | thinq1_uri: str
14 | thinq2_uri: str
15 | emp_uri: str
16 |
17 | @property
18 | def oauth_url(self) -> str:
19 | query = parse.urlencode(
20 | {
21 | "country": self.country_code,
22 | "language": self.language_code,
23 | "svc_list": thinq2.SERVICE_CODE,
24 | "client_id": thinq2.OAUTH_CLIENT_ID,
25 | "division": thinq2.DIVISION,
26 | "redirect_uri": thinq2.OAUTH_REDIRECT_URI,
27 | "state": uuid.uuid1().hex,
28 | "show_thirdparty_login": thinq2.THIRD_PARTY_LOGINS,
29 | }
30 | )
31 | return parse.urljoin(
32 | self.emp_uri, "spx/login/signIn?{query}".format(query=query)
33 | )
34 |
--------------------------------------------------------------------------------
/thinq2/model/mqtt.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from dataclasses import field
3 |
4 | from marshmallow_dataclass import dataclass
5 |
6 | from thinq2.model.common import Route
7 | from thinq2.model.thinq import IOTRegistration
8 | from thinq2.schema import CamelCaseSchema
9 |
10 |
11 | @dataclass
12 | class MQTTConfiguration:
13 | route: Route
14 | registration: IOTRegistration
15 | ca_cert: str
16 | private_key: str
17 | csr: str
18 |
19 |
20 | @dataclass
21 | class MQTTMessageDeviceState:
22 | desired: dict = field(default_factory=dict)
23 | reported: dict = field(default_factory=dict)
24 |
25 |
26 | @dataclass(base_schema=CamelCaseSchema)
27 | class MQTTMessageDeviceData:
28 | state: MQTTMessageDeviceState
29 |
30 |
31 | @dataclass(base_schema=CamelCaseSchema)
32 | class MQTTMessage:
33 | device_id: str
34 | message_type: str = field(metadata=dict(data_key="type"))
35 | data: MQTTMessageDeviceData
36 | timestamp: datetime = None
37 |
--------------------------------------------------------------------------------
/thinq2/model/thinq.py:
--------------------------------------------------------------------------------
1 | from enum import Enum
2 | from dataclasses import field, make_dataclass
3 | from typing import List
4 |
5 | import marshmallow_dataclass
6 |
7 | from marshmallow import Schema, fields, post_load, pre_load
8 | from marshmallow_dataclass import dataclass
9 | from marshmallow_enum import EnumField
10 |
11 | from inflection import underscore, camelize
12 |
13 | from thinq2.schema import CamelCaseSchema, BaseSchema
14 | from thinq2.model.device import Device, device_types
15 | from thinq2.util import memoize
16 |
17 |
18 | @dataclass(base_schema=CamelCaseSchema)
19 | class DeviceDescriptor:
20 | device_id: str
21 | model_name: str
22 | device_type: int
23 | alias: str
24 | model_country_code: str
25 | country_code: str
26 | fw_ver: str
27 | # image_file_name: str
28 | # image_url: str
29 | # small_image_url: str
30 | ssid: str
31 | mac_address: str
32 | network_type: str
33 | timezone_code: str
34 | timezone_code_alias: str
35 | utc_offset: int
36 | utc_offset_display: str
37 | dst_offset: int
38 | dst_offset_display: str
39 | cur_offset: int
40 | cur_offset_display: str
41 | new_reg_yn: str
42 | remote_control_type: str
43 | user_no: str
44 | # model_json_ver: float
45 | # model_json_uri: str
46 | # app_module_ver: float
47 | # app_module_uri: str
48 | # app_restart_yn: str
49 | # app_module_size: float
50 | # lang_pack_product_type_ver: float
51 | # lang_pack_product_type_uri: str
52 | device_state: str
53 | online: bool
54 | area: int
55 | reg_dt: float
56 | blackbox_yn: bool
57 | model_protocol: str
58 | order: int
59 | dr_service_yn: str
60 | # guide_type_yn: str
61 | # guide_type: str
62 | reg_dt_utc: str
63 | groupable_yn: str
64 | controllable_yn: str
65 | combined_product_yn: str
66 | master_yn: str
67 | tclcount: int
68 | snapshot: Device
69 |
70 | @post_load(pass_original=True)
71 | def polymorphism(self, item, data, **kwargs):
72 | device_schema = device_types.get(item.device_type, Device).Schema()
73 | item.snapshot = device_schema.load(data.get("snapshot", {}))
74 | return item
75 |
76 |
77 | @dataclass(base_schema=CamelCaseSchema)
78 | class DeviceCollection:
79 | items: List[DeviceDescriptor] = field(metadata=dict(data_key="item"))
80 |
81 | @pre_load
82 | def filter_items(self, data, **kwargs):
83 | """ Filter thinq1 devices as we don't currently support them """
84 | try:
85 | items = [i for i in data["item"] if i["platformType"] == "thinq2"]
86 | return {**data, **dict(item=items)}
87 | except KeyError as e:
88 | return data
89 |
90 |
91 | @dataclass(base_schema=CamelCaseSchema)
92 | class IOTRegistration:
93 | certificate_pem: str
94 | subscriptions: List[str]
95 |
96 |
97 | class ThinQResultCode(Enum):
98 | OK = "0000"
99 | PARTIAL_OK = "0001"
100 | OPERATION_IN_PROGRESS_DEVICE = "0103"
101 | PORTAL_INTERWORKING_ERROR = "0007"
102 | PROCESSING_REFRIGERATOR = "0104"
103 | RESPONSE_DELAY_DEVICE = "0111"
104 | SERVICE_SERVER_ERROR = "8107"
105 | SSP_ERROR = "8102"
106 | TIME_OUT = "9020"
107 | WRONG_XML_OR_URI = "9000"
108 |
109 | AWS_IOT_ERROR = "8104"
110 | AWS_S3_ERROR = "8105"
111 | AWS_SQS_ERROR = "8106"
112 | BASE64_DECODING_ERROR = "9002"
113 | BASE64_ENCODING_ERROR = "9001"
114 | CLIP_ERROR = "8103"
115 | CONTROL_ERROR_REFRIGERATOR = "0105"
116 | CREATE_SESSION_FAIL = "9003"
117 | DB_PROCESSING_FAIL = "9004"
118 | DM_ERROR = "8101"
119 | DUPLICATED_ALIAS = "0013"
120 | DUPLICATED_DATA = "0008"
121 | DUPLICATED_LOGIN = "0004"
122 | EMP_AUTHENTICATION_FAILED = "0102"
123 | ETC_COMMUNICATION_ERROR = "8900"
124 | ETC_ERROR = "9999"
125 | EXCEEDING_LIMIT = "0112"
126 | EXPIRED_CUSTOMER_NUMBER = "0119"
127 | EXPIRES_SESSION_BY_WITHDRAWAL = "9005"
128 | FAIL = "0100"
129 | INACTIVE_API = "8001"
130 | INSUFFICIENT_STORAGE_SPACE = "0107"
131 | INVAILD_CSR = "9010"
132 | INVALID_BODY = "0002"
133 | INVALID_CUSTOMER_NUMBER = "0118"
134 | INVALID_HEADER = "0003"
135 | INVALID_PUSH_TOKEN = "0301"
136 | INVALID_REQUEST_DATA_FOR_DIAGNOSIS = "0116"
137 | MISMATCH_DEVICE_GROUP = "0014"
138 | MISMATCH_LOGIN_SESSION = "0114"
139 | MISMATCH_NONCE = "0006"
140 | MISMATCH_REGISTRED_DEVICE = "0115"
141 | MISSING_SERVER_SETTING_INFORMATION = "9005"
142 | NOT_AGREED_TERMS = "0110"
143 | NOT_CONNECTED_DEVICE = "0106"
144 | NOT_CONTRACT_CUSTOMER_NUMBER = "0120"
145 | NOT_EXIST_DATA = "0010"
146 | NOT_EXIST_DEVICE = "0009"
147 | NOT_EXIST_MODEL_JSON = "0117"
148 | NOT_REGISTERED_SMART_CARE = "0121"
149 | NOT_SUPPORTED_COMMAND = "0012"
150 | NOT_SUPPORTED_COUNTRY = "8000"
151 | NOT_SUPPORTED_SERVICE = "0005"
152 | NO_INFORMATION_DR = "0109"
153 | NO_INFORMATION_SLEEP_MODE = "0108"
154 | NO_PERMISSION = "0011"
155 | NO_PERMMISION_MODIFY_RECIPE = "0113"
156 | NO_REGISTERED_DEVICE = "0101"
157 | NO_USER_INFORMATION = "9006"
158 |
159 |
160 | class ThinQException(Exception):
161 | pass
162 |
163 |
164 | class BaseThinQResult(Schema):
165 | result_code = EnumField(
166 | ThinQResultCode, data_key="resultCode", load_by=EnumField.VALUE
167 | )
168 | result = fields.Nested(Schema)
169 |
170 |
171 | class ThinQResultSuccess(BaseThinQResult):
172 | result = fields.Raw()
173 |
174 | @post_load
175 | def is_successful(self, data, **kwargs):
176 | return data["result_code"] == ThinQResultCode.OK
177 |
178 |
179 | class ThinQResult(BaseThinQResult):
180 | def __init__(self, result_class):
181 | self._result_schema = result_class.Schema()
182 | super().__init__()
183 |
184 | def on_bind_field(self, field_name, field):
185 | if isinstance(field, fields.Nested):
186 | # field = fields.Str()
187 | field.nested = self._result_schema
188 |
189 | @post_load
190 | def unwrap_result(self, data, **kwargs):
191 | if data["result_code"] != ThinQResultCode.OK:
192 | raise ThinQException(ThinQResultCode(data["result_code"]))
193 | return data["result"]
194 |
195 |
196 | @dataclass(base_schema=CamelCaseSchema)
197 | class ModelJsonDescriptor:
198 | """ ModelJSON metadata """
199 |
200 | model_json_ver: str
201 | model_json_uri: str
202 | timestamp: int
203 |
204 |
205 | class ModelJsonDataclass:
206 | """ Builds a marshmallow-enabled dataclass from an LG "modeljson" object """
207 |
208 | def __init__(self, model):
209 | self.model = model
210 |
211 | def build(self, dataclass_name=None):
212 | DeviceModel = make_dataclass(dataclass_name or self.model_type, self.fields)
213 | marshmallow_dataclass.add_schema(DeviceModel, base_schema=BaseSchema)
214 | DeviceModel.Enum = self.enums
215 | return DeviceModel
216 |
217 | def _field_definition(self, name, spec):
218 | key = underscore(name)
219 | field_type = self._field_type(key, spec)
220 | return (key, field_type, field(metadata=self._field_meta(name, field_type)))
221 |
222 | def _field_meta(self, name, field_type):
223 | return dict(data_key=name)
224 |
225 | def _field_type(self, name, spec):
226 | if "dataType" in spec:
227 | if spec["dataType"].lower() == "enum":
228 | return self._enum_field(name, self._map_values(spec["valueMapping"]))
229 |
230 | elif spec["dataType"].lower() == "range":
231 | # XXX - validation check
232 | return int
233 |
234 | elif "ref" in spec:
235 | return self._enum_field(name, self._ref_values(spec["ref"]))
236 |
237 | # XXX - non generic exceptions
238 | raise Exception("Unknown modelJson field type in {}".format(name))
239 |
240 | def _ref_values(self, ref):
241 | return list(self.model[ref].keys()) + ["NOT_SELECTED"]
242 |
243 | def _map_values(self, mappings):
244 | return {key: mapping["index"] for key, mapping in mappings.items()}
245 |
246 | def _enum_field(self, name, values):
247 | return Enum(camelize(name), values)
248 |
249 | @property
250 | @memoize
251 | def fields(self):
252 | return [
253 | self._field_definition(data_key, spec)
254 | for data_key, spec in self.model["MonitoringValue"].items()
255 | ]
256 |
257 | @property
258 | @memoize
259 | def enums(self):
260 | return {
261 | name: field for name, field, _ in self.fields if issubclass(field, Enum)
262 | }
263 |
264 | @property
265 | def model_type(self):
266 | return self.model.get("Info", {"modelType": "UnknownDevice"}).get("modelType")
267 |
--------------------------------------------------------------------------------
/thinq2/schema.py:
--------------------------------------------------------------------------------
1 | import re
2 | import inspect
3 |
4 | from dataclasses import is_dataclass
5 |
6 | from attrdict import AttrDict
7 | from marshmallow import EXCLUDE, Schema
8 | from inflection import camelize
9 |
10 | from thinq2.util import memoize
11 |
12 |
13 | class BaseSchema(Schema):
14 | class Meta:
15 | unknown = EXCLUDE
16 |
17 | def on_bind_field(self, field_name, field):
18 | field.data_key = self.transform(field.data_key or field_name)
19 |
20 | def transform(self, field_name):
21 | return field_name
22 |
23 |
24 | class CamelCaseSchema(BaseSchema):
25 | def transform(self, field_name):
26 | return camelize(field_name, uppercase_first_letter=False)
27 |
28 |
29 | # XXX - bad pun
30 | class CamelIDSchema(CamelCaseSchema):
31 | def transform(self, field_name):
32 | return re.sub(r"(?<=[a-z])Id(?=[A-Z]|$)", "ID", super().transform(field_name))
33 |
34 |
35 | class AbstractController:
36 | pass
37 |
38 |
39 | def controller_class(data_type, **children):
40 | schema = data_type.Schema()
41 |
42 | def merge_args(self, kwargs):
43 | attrs = {
44 | k: getattr(self, k, None) for k in schema.fields.keys() if not k in kwargs
45 | }
46 | defaults = schema.dump({k: v for k, v in attrs.items() if v is not None})
47 | return {**defaults, **kwargs}
48 |
49 | class Controller(AbstractController):
50 | _data: data_type = None
51 |
52 | # XXX - fix infinite recursion error if called w/ no args
53 | def __init__(self, data=None, *args, **kwargs):
54 | init = inspect.signature(super().__init__)
55 | pass_keys = AttrDict(
56 | filter(self._param_filter, init.parameters.items())
57 | ).keys()
58 | pass_params = {k: v for k, v in kwargs.items() if k in pass_keys}
59 |
60 | super().__init__(**pass_params)
61 |
62 | if data is None:
63 | self._data = AttrDict(kwargs)
64 | args = AttrDict(filter(self._args_filter, kwargs.items()))
65 | self._data = schema.load(merge_args(self, args))
66 | else:
67 | if is_dataclass(data):
68 | self._data = data
69 | else:
70 | self._data = schema.load(data)
71 |
72 | def _args_filter(self, item):
73 | key, value = item
74 | return not isinstance(value, AbstractController)
75 |
76 | def _param_filter(self, item):
77 | name, param = item
78 | return param.kind in [param.POSITIONAL_OR_KEYWORD, param.KEYWORD_ONLY]
79 |
80 | @classmethod
81 | def load(cls, data):
82 | return cls(schema.load(data))
83 |
84 | @property
85 | def __dict__(self):
86 | return schema.dump(self._data)
87 |
88 |
89 | # XXX - should throw attribute exception if attr not in schema
90 | def __getattr__(self, attr):
91 | if not attr.startswith("_"):
92 | if hasattr(self._data, attr):
93 | return getattr(self._data, attr)
94 |
95 | # XXX - fail if super doesn't havegetattr?
96 | return super().__getattr__(attr)
97 |
98 | def __setattr__(self, attr, value):
99 | if isinstance(self._data, data_type) and hasattr(self._data, attr):
100 | setattr(self._data, attr, value)
101 | else:
102 | super().__setattr__(attr, value)
103 |
104 | def class_wrapper(base_class):
105 | return type(base_class.__name__, (Controller, base_class), {})
106 |
107 | return class_wrapper
108 |
109 |
110 | def controller_factory(func):
111 | field_name = func.__name__
112 |
113 | @property
114 | @memoize
115 | def inner(self, *args, **kwargs):
116 | if isinstance(self._data, dict):
117 | existing = self._data.get(field_name, None)
118 | else:
119 | existing = getattr(self._data, field_name, None)
120 |
121 | if isinstance(existing, AbstractController):
122 | """ If it's already a controller, return it """
123 | return existing
124 |
125 | return func(self, existing)
126 |
127 | return inner
128 |
129 |
130 | def controller(class_or_func, **children):
131 | if isinstance(class_or_func, type):
132 | return controller_class(class_or_func, **children)
133 | if callable(class_or_func):
134 | return controller_factory(class_or_func)
135 |
136 |
137 | def initializer(obj, attr="_data"):
138 | @property
139 | def inner(self):
140 | data = getattr(self, attr)
141 | if data is None:
142 | return obj(self)
143 |
144 | value = getattr(data, obj.__name__, None) or obj(self)
145 | setattr(data, obj.__name__, value)
146 | return value
147 |
148 | return inner
149 |
--------------------------------------------------------------------------------
/thinq2/util/__init__.py:
--------------------------------------------------------------------------------
1 | def memoize(func):
2 | memo = {}
3 |
4 | def inner(*args, **kwargs):
5 | key = str(dict(args=args, kwargs=kwargs))
6 | if key not in memo:
7 | memo[key] = func(*args, **kwargs)
8 | return memo[key]
9 |
10 | return inner
11 |
12 |
13 | def end_with(string, end):
14 | if string.endswith(end):
15 | return string
16 | return string + end
17 |
--------------------------------------------------------------------------------
/thinq2/util/filesystem.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from tempfile import TemporaryDirectory, mkstemp
4 |
5 |
6 | class TempDir:
7 | def __init__(self):
8 | self._dir = TemporaryDirectory()
9 |
10 | def file(self, content: str = None):
11 | fh, path = mkstemp(dir=self._dir.name)
12 | if content is not None:
13 | os.write(fh, str.encode(content))
14 | os.close(fh)
15 | return path
16 |
--------------------------------------------------------------------------------
/thinq2/util/uuid.py:
--------------------------------------------------------------------------------
1 | import re
2 | import uuid
3 | import base64
4 |
5 |
6 | class ThinQMessageID(object):
7 | """Object that returns a new random ThinQ message ID each time it's referenced as a string"""
8 |
9 | def __str__(self):
10 | return re.sub(
11 | "=*$", "", base64.urlsafe_b64encode(uuid.uuid4().bytes).decode("UTF-8")
12 | )
13 |
--------------------------------------------------------------------------------