From afd38e571a99818c26216b810a6a3a241ac48344 Mon Sep 17 00:00:00 2001 From: "Benoit HERVIER (Khertan)" Date: Mon, 4 Apr 2011 18:28:28 +0200 Subject: [PATCH] Added necessary file for packaging and script to start ui and daemon --- .../lib/khweeteur}/__init__.py | 0 .../lib/khweeteur}/bitly.py | 0 .../lib/khweeteur}/daemon.py | 2 +- .../lib/khweeteur}/icons/favorite.png | Bin .../khweeteur}/icons/general_chat_button.png | Bin .../icons/general_presence_home.png | Bin .../lib/khweeteur}/icons/geoloc.png | Bin .../lib/khweeteur}/icons/khweeteur.png | Bin .../lib/khweeteur}/icons/reply.png | Bin .../lib/khweeteur}/icons/retweet.png | Bin .../khweeteur}/icons/tasklaunch_sms_chat.png | Bin .../lib/khweeteur}/list_model.py | 0 .../lib/khweeteur}/list_view.py | 0 .../lib/khweeteur}/notifications.py | 0 .../lib/khweeteur}/qbadgebutton.py | 0 .../lib/khweeteur}/qml_gui.py | 0 .../lib/khweeteur}/qwidget_gui.py | 13 +- .../lib/khweeteur}/retriever.py | 0 .../lib/khweeteur}/settings.py | 0 .../lib/khweeteur}/tweetslist.py | 0 .../lib/khweeteur}/twitpic.py | 0 .../lib/khweeteur}/twitter.py | 0 build/scripts-2.5/khweeteur | 7 + icons/hicolor/128x128/apps/khweeteur.png | Bin 0 -> 8758 bytes icons/hicolor/32x32/apps/khweeteur.png | Bin 0 -> 1137 bytes .../hicolor/64x64/apps}/khweeteur.png | Bin khweeteur-experimental/daemon.pyo | Bin 3073 -> 0 bytes khweeteur-experimental/retriever.pyo | Bin 8329 -> 0 bytes khweeteur-experimental/tweetslist.pyo | Bin 10657 -> 0 bytes khweeteur-experimental/twitter.pyo | Bin 125308 -> 0 bytes khweeteur.desktop | 7 + khweeteur.png | Bin 0 -> 8758 bytes khweeteur.service | 3 + khweeteur/__init__.py | 16 + khweeteur/bitly.py | 213 + khweeteur/daemon.py | 625 +++ khweeteur/icons/favorite.png | Bin 0 -> 1341 bytes khweeteur/icons/general_chat_button.png | Bin 0 -> 4592 bytes khweeteur/icons/general_presence_home.png | Bin 0 -> 2015 bytes khweeteur/icons/geoloc.png | Bin 0 -> 292 bytes khweeteur/icons/khweeteur.png | Bin 0 -> 3106 bytes khweeteur/icons/reply.png | Bin 0 -> 1156 bytes khweeteur/icons/retweet.png | Bin 0 -> 1093 bytes khweeteur/icons/tasklaunch_sms_chat.png | Bin 0 -> 6988 bytes khweeteur/list_model.py | 227 ++ khweeteur/list_view.py | 649 +++ khweeteur/notifications.py | 84 + .../old/__init__.py | 0 .../old/client.py | 0 .../old/client2.py | 0 .../old/daemon.py | 0 .../old/oauth2/__init__.py | 0 .../old/oauth2/__init__.pyc | Bin .../old/oauth2/__init__.pyo | Bin .../old/oauth2/clients/__init__.py | 0 .../old/oauth2/clients/__init__.pyc | Bin .../old/oauth2/clients/imap.py | 0 .../old/oauth2/clients/imap.pyc | Bin .../old/oauth2/clients/smtp.py | 0 .../old/oauth2/clients/smtp.pyc | Bin .../old/objects.py | 0 .../old/tweetslist.py | 0 .../old/tweetslist.pyo | Bin .../old/tweetslist.qml | 0 .../old/twitter.py | 0 .../old/twitter.pyo | Bin khweeteur/qbadgebutton.py | 145 + .../qml/add.png | Bin .../qml/default.png | Bin .../qml/fullsize.png | Bin .../qml/house.png | Bin khweeteur/qml/khweeteur.png | Bin 0 -> 2876 bytes .../qml/refresh.png | Bin .../qml/tweetslist.qml | 0 khweeteur/qml_gui.py | 106 + khweeteur/qwidget_gui.py | 773 ++++ khweeteur/retriever.py | 216 + khweeteur/settings.py | 448 ++ khweeteur/tweetslist.py | 205 + khweeteur/twitpic.py | 451 +++ khweeteur/twitter.py | 3597 +++++++++++++++++ khweeteur_32.png | Bin 0 -> 1137 bytes khweeteur_64.png | Bin 0 -> 2876 bytes khweeteurd | 15 + scripts/khweeteur | 7 + setup.py | 98 + 86 files changed, 7903 insertions(+), 4 deletions(-) rename {khweeteur-experimental => build/lib/khweeteur}/__init__.py (100%) rename {khweeteur-experimental => build/lib/khweeteur}/bitly.py (100%) rename {khweeteur-experimental => build/lib/khweeteur}/daemon.py (99%) rename {khweeteur-experimental => build/lib/khweeteur}/icons/favorite.png (100%) rename {khweeteur-experimental => build/lib/khweeteur}/icons/general_chat_button.png (100%) rename {khweeteur-experimental => build/lib/khweeteur}/icons/general_presence_home.png (100%) rename {khweeteur-experimental => build/lib/khweeteur}/icons/geoloc.png (100%) rename {khweeteur-experimental => build/lib/khweeteur}/icons/khweeteur.png (100%) rename {khweeteur-experimental => build/lib/khweeteur}/icons/reply.png (100%) rename {khweeteur-experimental => build/lib/khweeteur}/icons/retweet.png (100%) rename {khweeteur-experimental => build/lib/khweeteur}/icons/tasklaunch_sms_chat.png (100%) rename {khweeteur-experimental => build/lib/khweeteur}/list_model.py (100%) rename {khweeteur-experimental => build/lib/khweeteur}/list_view.py (100%) rename {khweeteur-experimental => build/lib/khweeteur}/notifications.py (100%) rename {khweeteur-experimental => build/lib/khweeteur}/qbadgebutton.py (100%) rename {khweeteur-experimental => build/lib/khweeteur}/qml_gui.py (100%) rename {khweeteur-experimental => build/lib/khweeteur}/qwidget_gui.py (98%) rename {khweeteur-experimental => build/lib/khweeteur}/retriever.py (100%) rename {khweeteur-experimental => build/lib/khweeteur}/settings.py (100%) rename {khweeteur-experimental => build/lib/khweeteur}/tweetslist.py (100%) rename {khweeteur-experimental => build/lib/khweeteur}/twitpic.py (100%) rename {khweeteur-experimental => build/lib/khweeteur}/twitter.py (100%) create mode 100644 build/scripts-2.5/khweeteur create mode 100644 icons/hicolor/128x128/apps/khweeteur.png create mode 100644 icons/hicolor/32x32/apps/khweeteur.png rename {khweeteur-experimental/qml => icons/hicolor/64x64/apps}/khweeteur.png (100%) delete mode 100644 khweeteur-experimental/daemon.pyo delete mode 100644 khweeteur-experimental/retriever.pyo delete mode 100644 khweeteur-experimental/tweetslist.pyo delete mode 100644 khweeteur-experimental/twitter.pyo create mode 100644 khweeteur.desktop create mode 100644 khweeteur.png create mode 100644 khweeteur.service create mode 100644 khweeteur/__init__.py create mode 100644 khweeteur/bitly.py create mode 100644 khweeteur/daemon.py create mode 100644 khweeteur/icons/favorite.png create mode 100644 khweeteur/icons/general_chat_button.png create mode 100644 khweeteur/icons/general_presence_home.png create mode 100644 khweeteur/icons/geoloc.png create mode 100644 khweeteur/icons/khweeteur.png create mode 100644 khweeteur/icons/reply.png create mode 100644 khweeteur/icons/retweet.png create mode 100644 khweeteur/icons/tasklaunch_sms_chat.png create mode 100644 khweeteur/list_model.py create mode 100644 khweeteur/list_view.py create mode 100644 khweeteur/notifications.py rename {khweeteur-experimental => khweeteur}/old/__init__.py (100%) rename {khweeteur-experimental => khweeteur}/old/client.py (100%) rename {khweeteur-experimental => khweeteur}/old/client2.py (100%) rename {khweeteur-experimental => khweeteur}/old/daemon.py (100%) rename {khweeteur-experimental => khweeteur}/old/oauth2/__init__.py (100%) rename {khweeteur-experimental => khweeteur}/old/oauth2/__init__.pyc (100%) rename {khweeteur-experimental => khweeteur}/old/oauth2/__init__.pyo (100%) rename {khweeteur-experimental => khweeteur}/old/oauth2/clients/__init__.py (100%) rename {khweeteur-experimental => khweeteur}/old/oauth2/clients/__init__.pyc (100%) rename {khweeteur-experimental => khweeteur}/old/oauth2/clients/imap.py (100%) rename {khweeteur-experimental => khweeteur}/old/oauth2/clients/imap.pyc (100%) rename {khweeteur-experimental => khweeteur}/old/oauth2/clients/smtp.py (100%) rename {khweeteur-experimental => khweeteur}/old/oauth2/clients/smtp.pyc (100%) rename {khweeteur-experimental => khweeteur}/old/objects.py (100%) rename {khweeteur-experimental => khweeteur}/old/tweetslist.py (100%) rename {khweeteur-experimental => khweeteur}/old/tweetslist.pyo (100%) rename {khweeteur-experimental => khweeteur}/old/tweetslist.qml (100%) rename {khweeteur-experimental => khweeteur}/old/twitter.py (100%) rename {khweeteur-experimental => khweeteur}/old/twitter.pyo (100%) create mode 100644 khweeteur/qbadgebutton.py rename {khweeteur-experimental => khweeteur}/qml/add.png (100%) rename {khweeteur-experimental => khweeteur}/qml/default.png (100%) rename {khweeteur-experimental => khweeteur}/qml/fullsize.png (100%) rename {khweeteur-experimental => khweeteur}/qml/house.png (100%) create mode 100644 khweeteur/qml/khweeteur.png rename {khweeteur-experimental => khweeteur}/qml/refresh.png (100%) rename {khweeteur-experimental => khweeteur}/qml/tweetslist.qml (100%) create mode 100644 khweeteur/qml_gui.py create mode 100644 khweeteur/qwidget_gui.py create mode 100644 khweeteur/retriever.py create mode 100644 khweeteur/settings.py create mode 100644 khweeteur/tweetslist.py create mode 100644 khweeteur/twitpic.py create mode 100644 khweeteur/twitter.py create mode 100644 khweeteur_32.png create mode 100644 khweeteur_64.png create mode 100644 khweeteurd create mode 100644 scripts/khweeteur create mode 100644 setup.py diff --git a/khweeteur-experimental/__init__.py b/build/lib/khweeteur/__init__.py similarity index 100% rename from khweeteur-experimental/__init__.py rename to build/lib/khweeteur/__init__.py diff --git a/khweeteur-experimental/bitly.py b/build/lib/khweeteur/bitly.py similarity index 100% rename from khweeteur-experimental/bitly.py rename to build/lib/khweeteur/bitly.py diff --git a/khweeteur-experimental/daemon.py b/build/lib/khweeteur/daemon.py similarity index 99% rename from khweeteur-experimental/daemon.py rename to build/lib/khweeteur/daemon.py index cdffb70..a0e2115 100644 --- a/khweeteur-experimental/daemon.py +++ b/build/lib/khweeteur/daemon.py @@ -608,7 +608,7 @@ def retrieve(self, options=None): if __name__ == "__main__": install_excepthook(__version__) - daemon = KhweeteurDaemon('/tmp/khweeteur.pid') + daemon = KhweeteurDaemon('/var/run/khweeteurd/khweeteurd.pid') if len(sys.argv) == 2: if 'start' == sys.argv[1]: daemon.start() diff --git a/khweeteur-experimental/icons/favorite.png b/build/lib/khweeteur/icons/favorite.png similarity index 100% rename from khweeteur-experimental/icons/favorite.png rename to build/lib/khweeteur/icons/favorite.png diff --git a/khweeteur-experimental/icons/general_chat_button.png b/build/lib/khweeteur/icons/general_chat_button.png similarity index 100% rename from khweeteur-experimental/icons/general_chat_button.png rename to build/lib/khweeteur/icons/general_chat_button.png diff --git a/khweeteur-experimental/icons/general_presence_home.png b/build/lib/khweeteur/icons/general_presence_home.png similarity index 100% rename from khweeteur-experimental/icons/general_presence_home.png rename to build/lib/khweeteur/icons/general_presence_home.png diff --git a/khweeteur-experimental/icons/geoloc.png b/build/lib/khweeteur/icons/geoloc.png similarity index 100% rename from khweeteur-experimental/icons/geoloc.png rename to build/lib/khweeteur/icons/geoloc.png diff --git a/khweeteur-experimental/icons/khweeteur.png b/build/lib/khweeteur/icons/khweeteur.png similarity index 100% rename from khweeteur-experimental/icons/khweeteur.png rename to build/lib/khweeteur/icons/khweeteur.png diff --git a/khweeteur-experimental/icons/reply.png b/build/lib/khweeteur/icons/reply.png similarity index 100% rename from khweeteur-experimental/icons/reply.png rename to build/lib/khweeteur/icons/reply.png diff --git a/khweeteur-experimental/icons/retweet.png b/build/lib/khweeteur/icons/retweet.png similarity index 100% rename from khweeteur-experimental/icons/retweet.png rename to build/lib/khweeteur/icons/retweet.png diff --git a/khweeteur-experimental/icons/tasklaunch_sms_chat.png b/build/lib/khweeteur/icons/tasklaunch_sms_chat.png similarity index 100% rename from khweeteur-experimental/icons/tasklaunch_sms_chat.png rename to build/lib/khweeteur/icons/tasklaunch_sms_chat.png diff --git a/khweeteur-experimental/list_model.py b/build/lib/khweeteur/list_model.py similarity index 100% rename from khweeteur-experimental/list_model.py rename to build/lib/khweeteur/list_model.py diff --git a/khweeteur-experimental/list_view.py b/build/lib/khweeteur/list_view.py similarity index 100% rename from khweeteur-experimental/list_view.py rename to build/lib/khweeteur/list_view.py diff --git a/khweeteur-experimental/notifications.py b/build/lib/khweeteur/notifications.py similarity index 100% rename from khweeteur-experimental/notifications.py rename to build/lib/khweeteur/notifications.py diff --git a/khweeteur-experimental/qbadgebutton.py b/build/lib/khweeteur/qbadgebutton.py similarity index 100% rename from khweeteur-experimental/qbadgebutton.py rename to build/lib/khweeteur/qbadgebutton.py diff --git a/khweeteur-experimental/qml_gui.py b/build/lib/khweeteur/qml_gui.py similarity index 100% rename from khweeteur-experimental/qml_gui.py rename to build/lib/khweeteur/qml_gui.py diff --git a/khweeteur-experimental/qwidget_gui.py b/build/lib/khweeteur/qwidget_gui.py similarity index 98% rename from khweeteur-experimental/qwidget_gui.py rename to build/lib/khweeteur/qwidget_gui.py index 4807c4a..7e63f3f 100644 --- a/khweeteur-experimental/qwidget_gui.py +++ b/build/lib/khweeteur/qwidget_gui.py @@ -48,6 +48,7 @@ def __init__(self,parent): @dbus.service.signal(dbus_interface='net.khertan.Khweeteur') def require_update(self,optional=None): self.parent.setAttribute(Qt.WA_Maemo5ShowProgressIndicator , True) + print 'DEBUG : require_update' @dbus.service.signal(dbus_interface='net.khertan.Khweeteur', signature='uussssss') @@ -61,6 +62,7 @@ def post_tweet(self, \ action = '', tweet_id = '0', ): + print 'DEBUG : post_tweet' pass class KhweeteurAbout(QMainWindow): @@ -380,11 +382,12 @@ def listen_dbus(self): dbus.set_default_main_loop(self.dbus_loop) self.bus = dbus.SessionBus() #Connect the new tweet signal - self.bus.add_signal_receiver(self.new_tweets, path='/net/khertan/Khweeteur', dbus_interface='net.khertan.Khweeteur', signal_name='new_tweets') - self.bus.add_signal_receiver(self.stop_spinning, path='/net/khertan/Khweeteur', dbus_interface='net.khertan.Khweeteur', signal_name='refresh_ended') + self.bus.add_signal_receiver(self.new_tweets, path='/net/khertan/Khweeteur', dbus_interface='net.khertan.Khweeteur', signal_name='new_tweets') + self.bus.add_signal_receiver(self.stop_spinning, path='/net/khertan/Khweeteur', dbus_interface='net.khertan.Khweeteur', signal_name='refresh_ended') self.dbus_handler = KhweeteurDBusHandler(self) def stop_spinning(self): + print 'DEBUG : stop_spinning' self.setAttribute(Qt.WA_Maemo5ShowProgressIndicator , False) def new_tweets(self,count,msg): @@ -404,12 +407,16 @@ def new_tweets(self,count,msg): QApplication.processEvents() if self.model.call == msg: + print 'DEBUG : new_tweets model.load' self.model.load(msg) + print 'DEBUG : new_tweet end model.load' + + print 'DEBUG : end new_tweet' @pyqtSlot() def show_search(self): terms = self.sender().text() - print 'show_search %s' % (terms,) + self.tb_search_button.setCounter(0) self.home_button.setChecked(False) self.msg_button.setChecked(False) self.tb_search_button.setChecked(True) diff --git a/khweeteur-experimental/retriever.py b/build/lib/khweeteur/retriever.py similarity index 100% rename from khweeteur-experimental/retriever.py rename to build/lib/khweeteur/retriever.py diff --git a/khweeteur-experimental/settings.py b/build/lib/khweeteur/settings.py similarity index 100% rename from khweeteur-experimental/settings.py rename to build/lib/khweeteur/settings.py diff --git a/khweeteur-experimental/tweetslist.py b/build/lib/khweeteur/tweetslist.py similarity index 100% rename from khweeteur-experimental/tweetslist.py rename to build/lib/khweeteur/tweetslist.py diff --git a/khweeteur-experimental/twitpic.py b/build/lib/khweeteur/twitpic.py similarity index 100% rename from khweeteur-experimental/twitpic.py rename to build/lib/khweeteur/twitpic.py diff --git a/khweeteur-experimental/twitter.py b/build/lib/khweeteur/twitter.py similarity index 100% rename from khweeteur-experimental/twitter.py rename to build/lib/khweeteur/twitter.py diff --git a/build/scripts-2.5/khweeteur b/build/scripts-2.5/khweeteur new file mode 100644 index 0000000..baf2406 --- /dev/null +++ b/build/scripts-2.5/khweeteur @@ -0,0 +1,7 @@ +#!/bin/sh +if [ $# = 1 ] +then + exec python /usr/lib/python2.5/site-packages/khweeteur/daemon.py +else + exec python /usr/lib/python2.5/site-packages/khweeteur/__init__.py +fi diff --git a/icons/hicolor/128x128/apps/khweeteur.png b/icons/hicolor/128x128/apps/khweeteur.png new file mode 100644 index 0000000000000000000000000000000000000000..f6d329451bfc0d107f146ca5118240ca5cc49776 GIT binary patch literal 8758 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 SOY~3H0Z@=pm9CWp2LB&rvE*d{ literal 0 HcmV?d00001 diff --git a/icons/hicolor/32x32/apps/khweeteur.png b/icons/hicolor/32x32/apps/khweeteur.png new file mode 100644 index 0000000000000000000000000000000000000000..5b5c9b9aeba54bd483eb6c9d234c2382f2c9cd79 GIT binary patch literal 1137 zcmV-%1djWOP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vG?7XSb*7Xe&ki8=rP02y>eSaefwW^{L9 za%BKeVQFr3E>1;MAa*k@H7+$tiu~XJ00ZGkL_t(oN9|Q_OdDkw9|9fiaLy5i4hcgA z1Is>WMld9X)C5D~lFdY8X8fQgGa3{3VShlE^~?6b_$Ncnl9FXh97Yqj7D}&OS=T>z z$I&~WtZs|OGP*+ByY~*tpF;VtDUa`aTU(sqTG=P&@fVGJd8Uj=7t?bA?@1I*f~U;Bdf#xuDMA8ryrCZ95rUD6O~0sN#zXkSH?A@R%9F zQx-9NBp{cqi^N_ImDO3r@l^AYv*Vy^_kb1?C^_tc%^e=7ybzXEU^QgeZj~XjbQ@0I zn1cqp`2D&Fkm7d)RK2Yig~((CNYfnw?ePK78930KpTK)lDsYi{S3vKbf;-E9KzBF+ zT3plYjCGs{E|si{$lI(`{bZknNf3##)E5v2CZRwf*;}DNDgJG^J`44iFj>5>Goj@c zXSC~Y1O#b%Q!cCy2ugKTpEQvHTM4}Sy4)g9zU7;0TouRobNHo$gQ2EuNOVRPG>wo z-ATzv8b8QuLad5Xz&N%5_Gl7pYAmX2Q#f%hYw%R!9v1D}+9Smu1e|(aVNu{*Xc140 z0|TeR5nc52k@=in@HE+XhfvK9tK`w#Q=o7a*$DwM{qbvaSaLiy4)u;F$KXLWI=emR z=~ot<4J{WmT@epd^dLoZ@8fd6DwS3Rtk`b?Y{%16c-h>)8Qb&oDkc z%kp?F=y`rb&-+GpQK^IqL7V=d(yBda3AKdGiO<^E`uH4o zciqHNa#e1445iWTF_-2mnh6*lAoL%qEemgrm^UbV(`LkNS@wQM{;dMm`a1)T3K-O)j2``YH~Oq0FaBX|E%WYVv)Z0MIEaoV>d*N$~J z8YSMZoL7<`IO7=`a+|kYMh7mn;CVn* zXo92pmX84=O7v@;h%3999Yo$;fw0kxT*sS%zUu0Yra;mNv+Fl`KG4oZJ*-KP;>hdn zz&nmzBf3^^#P!Buz44;nc(B`ewA6y8bw_+HAyL0F4>IgdOV?`0jg^TcL+y4#sK=(;``YKyZ|A0_`K zjwK(qhvV=f)UvbP20Bt}swAKJU`17fO0XUX9XW?)bOfhp+*COunJXTw0q0cCFpvp! z3wSc*s#ahUrXLCScn{bri&zJLH?Vk`*CL|qEuOiWt1Zsdf~?@K67FIRgr6tG7^H;| zC2$j8>co*RPh@-FYC0=O$}G_-DmiPd$Q)LH@_BY(4ARfW<#S^s(M&EG#Fex}XLEFl z`iL>E|LP|?;EOHGY+^MgIP4((l;%(wUY04_LA5@^lfZzQ*ey3>SiXm^G83hEbiVPc zWY*X{oS~?akogVdC6}7V|KPJ5ryU)8&H|g)ueM3Owy&ea{CZ`P%y)kv#vJU)a(%iy zNd@6w)ahiS)H}(hdl(`~U40@I1kyscM>c^MQK_=jjRxAb`+6+pZzgcM1G)dK6|S znT2~vhT`TUEp_@(BdbFl#a~z(jYWw`yP2S!w}Q4&+S6hla!_zzrm;Q=IS~cmd}Q+^ z*XcwN*TuWmW>)HqKI!P8H(464LmrCJ{I1CEdr{(ald~wi#N7wHzzLNewx>=Ppy5-c z&K}GABSva#-+v)sTd$(IU38=hL0MG`HB}C_R8?V&f^bXU>V5Opr+Q< zrC?n(#bX?mcAG1CyNzt*32OBy!Bi?v$fv!%lZ@V1h2x(H>~zPLmDFNQI0J(~nReST zi+22A__kDT4^13igBTAk!o=I0$D7ZJ@XLUS;Fy5V(|(#Ar}a)Y7({7oAR_l6x)Hy7 zyPP@#KIPq~lbuaAC8wSlN}xfwXlQcL*vWDHbjVs>r$hN=!-?@^yl-N?{nX#dtd`eE zQb7nfeP67QR#+&?;=_^id`li|^Q}3nwtI;=44J|LdS*vD`izsveT=T6YN{4olD-nu@K~{=8f@OGf}NXcLoH8R KPy~mWqkjVz{+_o0 diff --git a/khweeteur-experimental/retriever.pyo b/khweeteur-experimental/retriever.pyo deleted file mode 100644 index ae23fc8766debf46b7826d29708925182d841828..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8329 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 Q%X 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 X=}vzy_T~GIx98Slxyp8JWtQNdQ$Ai{ diff --git a/khweeteur.desktop b/khweeteur.desktop new file mode 100644 index 0000000..4562e00 --- /dev/null +++ b/khweeteur.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Encoding=UTF-8 +Version=1.0 +Type=Application +Name=Khweeteur +Exec=/usr/bin/khweeteur_launch.py +Icon=khweeteur diff --git a/khweeteur.png b/khweeteur.png new file mode 100644 index 0000000000000000000000000000000000000000..f6d329451bfc0d107f146ca5118240ca5cc49776 GIT binary patch literal 8758 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 SOY~3H0Z@=pm9CWp2LB&rvE*d{ literal 0 HcmV?d00001 diff --git a/khweeteur.service b/khweeteur.service new file mode 100644 index 0000000..7fb8f07 --- /dev/null +++ b/khweeteur.service @@ -0,0 +1,3 @@ +[D-BUS Service] +Name=net.khertan.khweeteur +Exec=/usr/bin/khweeteur_launch.py diff --git a/khweeteur/__init__.py b/khweeteur/__init__.py new file mode 100644 index 0000000..982943d --- /dev/null +++ b/khweeteur/__init__.py @@ -0,0 +1,16 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2010 Benot HERVIER +# Licenced under GPLv3 + +'''A Twitter client made with Python and Qt''' + +from qwidget_gui import Khweeteur +import os.path + +if __name__ == '__main__': + from subprocess import Popen + Popen(['/usr/bin/python',os.path.join(os.path.dirname(__file__),'daemon.py'),'start']) + app = Khweeteur() + app.exec_() diff --git a/khweeteur/bitly.py b/khweeteur/bitly.py new file mode 100644 index 0000000..9268cac --- /dev/null +++ b/khweeteur/bitly.py @@ -0,0 +1,213 @@ +#!/usr/bin/python2.4 +# +# Copyright 2009 Empeeric LTD. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import simplejson +import urllib,urllib2 +import urlparse +import string + +BITLY_BASE_URL = "http://api.bit.ly/" +BITLY_API_VERSION = "2.0.1" + +VERBS_PARAM = { + 'shorten':'longUrl', + 'expand':'shortUrl', + 'info':'shortUrl', + 'stats':'shortUrl', + 'errors':'', +} + +class BitlyError(Exception): + '''Base class for bitly errors''' + + @property + def message(self): + '''Returns the first argument used to construct this error.''' + return self.args[0] + +class Api(object): + """ API class for bit.ly """ + def __init__(self, login, apikey): + self.login = login + self.apikey = apikey + self._urllib = urllib2 + + def shorten(self,longURL): + """ + Takes either: + A long URL string and returns shortened URL string + Or a list of long URL strings and returns a list of shortened URL strings. + """ + if not isinstance(longURL, list): + longURL = [longURL] + + for index,url in enumerate(longURL): + if not '://' in url: + longURL[index] = "http://" + url + + request = self._getURL("shorten",longURL) + result = self._fetchUrl(request) + json = simplejson.loads(result) + self._CheckForError(json) + + res = [] + for item in json['results'].values(): + if item['shortKeywordUrl'] == "": + res.append(item['shortUrl']) + else: + res.append(item['shortKeywordUrl']) + + if len(res) == 1: + return res[0] + else: + return res + + def expand(self,shortURL): + """ Given a bit.ly url or hash, return long source url """ + request = self._getURL("expand",shortURL) + result = self._fetchUrl(request) + json = simplejson.loads(result) + self._CheckForError(json) + return json['results'][string.split(shortURL, '/')[-1]]['longUrl'] + + def info(self,shortURL): + """ + Given a bit.ly url or hash, + return information about that page, + such as the long source url + """ + request = self._getURL("info",shortURL) + result = self._fetchUrl(request) + json = simplejson.loads(result) + self._CheckForError(json) + return json['results'][string.split(shortURL, '/')[-1]] + + def stats(self,shortURL): + """ Given a bit.ly url or hash, return traffic and referrer data. """ + request = self._getURL("stats",shortURL) + result = self._fetchUrl(request) + json = simplejson.loads(result) + self._CheckForError(json) + return Stats.NewFromJsonDict(json['results']) + + def errors(self): + """ Get a list of bit.ly API error codes. """ + request = self._getURL("errors","") + result = self._fetchUrl(request) + json = simplejson.loads(result) + self._CheckForError(json) + return json['results'] + + def setUrllib(self, urllib): + '''Override the default urllib implementation. + + Args: + urllib: an instance that supports the same API as the urllib2 module + ''' + self._urllib = urllib + + def _getURL(self,verb,paramVal): + if not isinstance(paramVal, list): + paramVal = [paramVal] + + params = [ + ('version',BITLY_API_VERSION), + ('format','json'), + ('login',self.login), + ('apiKey',self.apikey), + ] + + verbParam = VERBS_PARAM[verb] + if verbParam: + for val in paramVal: + params.append(( verbParam,val )) + + encoded_params = urllib.urlencode(params) + return "%s%s?%s" % (BITLY_BASE_URL,verb,encoded_params) + + def _fetchUrl(self,url): + '''Fetch a URL + + Args: + url: The URL to retrieve + + Returns: + A string containing the body of the response. + ''' + + # Open and return the URL + url_data = self._urllib.urlopen(url).read() + return url_data + + def _CheckForError(self, data): + """Raises a BitlyError if bitly returns an error message. + + Args: + data: A python dict created from the bitly json response + Raises: + BitlyError wrapping the bitly error message if one exists. + """ + # bitly errors are relatively unlikely, so it is faster + # to check first, rather than try and catch the exception + if 'ERROR' in data or data['statusCode'] == 'ERROR': + raise BitlyError, data['errorMessage'] + for key in data['results']: + if type(data['results']) is dict and type(data['results'][key]) is dict: + if 'statusCode' in data['results'][key] and data['results'][key]['statusCode'] == 'ERROR': + raise BitlyError, data['results'][key]['errorMessage'] + +class Stats(object): + '''A class representing the Statistics returned by the bitly api. + + The Stats structure exposes the following properties: + status.user_clicks # read only + status.clicks # read only + ''' + + def __init__(self,user_clicks=None,total_clicks=None): + self.user_clicks = user_clicks + self.total_clicks = total_clicks + + @staticmethod + def NewFromJsonDict(data): + '''Create a new instance based on a JSON dict. + + Args: + data: A JSON dict, as converted from the JSON in the bitly API + Returns: + A bitly.Stats instance + ''' + return Stats(user_clicks=data.get('userClicks', None), + total_clicks=data.get('clicks', None)) + + +if __name__ == '__main__': + testURL1="www.yahoo.com" + testURL2="www.cnn.com" + a=Api(login="pythonbitly",apikey="R_06871db6b7fd31a4242709acaf1b6648") + short=a.shorten(testURL1) + print "Short URL = %s" % short + urlList=[testURL1,testURL2] + shortList=a.shorten(urlList) + print "Short URL list = %s" % shortList + long=a.expand(short) + print "Expanded URL = %s" % long + info=a.info(short) + print "Info: %s" % info + stats=a.stats(short) + print "User clicks %s, total clicks: %s" % (stats.user_clicks,stats.total_clicks) + errors=a.errors() + print "Errors: %s" % errors diff --git a/khweeteur/daemon.py b/khweeteur/daemon.py new file mode 100644 index 0000000..a0e2115 --- /dev/null +++ b/khweeteur/daemon.py @@ -0,0 +1,625 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# Copyright (c) 2010 Benoît HERVIER +# Licenced under GPLv3 + +#import sip +#sip.setapi('QString', 2) +#sip.setapi('QVariant', 2) + +from __future__ import with_statement + +import sys +import time +from PySide.QtCore import QSettings +import atexit +import os +from signal import SIGTERM + +import logging + +from retriever import KhweeteurRefreshWorker +from settings import SUPPORTED_ACCOUNTS +import gobject +gobject.threads_init() +import socket +import pickle +import re + +__version__ = '0.5.0' + +import dbus +from dbus.mainloop.glib import DBusGMainLoop +DBusGMainLoop(set_as_default=True) +import threading + +import twitter +from urllib import urlretrieve +import urllib2 +import pickle +import glob + +try: + from PIL import Image +except: + import Image + +from PySide.QtCore import QSettings + +from threading import Thread + +import logging +import os +import os.path +import dbus +import dbus.service + + +#A hook to catch errors +def install_excepthook(version): + '''Install an excepthook called at each unexcepted error''' + __version__ = version + + def my_excepthook(exctype, value, tb): + '''Method which replace the native excepthook''' + #traceback give us all the errors information message like + # the method, file line ... everything like + # we have in the python interpreter + import traceback + trace_s = ''.join(traceback.format_exception(exctype, value, tb)) + print 'Except hook called : %s' % (trace_s) + formatted_text = "%s Version %s\nTrace : %s" % ('Khweeteur', __version__, trace_s) + logging.error(formatted_text) + + sys.excepthook = my_excepthook + + +class Daemon: + """ + A generic daemon class. + Usage: subclass the Daemon class and override the run() method + """ + + def __init__(self, pidfile, stdin='/dev/null', stdout='/dev/null', stderr='/dev/null'): + self.stdin = stdin + self.stdout = stdout + self.stderr = stderr + self.pidfile = pidfile + + def daemonize(self): + """ + do the UNIX double-fork magic, see Stevens' "Advanced + Programming in the UNIX Environment" for details (ISBN 0201563177) + http://www.erlenstar.demon.co.uk/unix/faq_2.html#SEC16 + """ + try: + pid = os.fork() + if pid > 0: + # exit first parent + sys.exit(0) + except OSError, e: + sys.stderr.write("fork #1 failed: %d (%s)\n" % (e.errno, e.strerror)) + sys.exit(1) + + # decouple from parent environment + os.chdir("/") + os.setsid() + os.umask(0) + + # do second fork + try: + pid = os.fork() + if pid > 0: + # exit from second parent + sys.exit(0) + except OSError, e: + sys.stderr.write("fork #2 failed: %d (%s)\n" % (e.errno, e.strerror)) + sys.exit(1) + + # redirect standard file descriptors + sys.stdout.flush() + sys.stderr.flush() + si = file(self.stdin, 'r') + so = file(self.stdout, 'a+') + se = file(self.stderr, 'a+', 0) + os.dup2(si.fileno(), sys.stdin.fileno()) + os.dup2(so.fileno(), sys.stdout.fileno()) + os.dup2(se.fileno(), sys.stderr.fileno()) + + # write pidfile + atexit.register(self.delpid) + pid = str(os.getpid()) + file(self.pidfile, 'w+').write("%s\n" % pid) + + def delpid(self): + os.remove(self.pidfile) + + def start(self): + """ + Start the daemon + """ + # Check for a pidfile to see if the daemon already runs + try: + pf = file(self.pidfile, 'r') + pid = int(pf.read().strip()) + pf.close() + except IOError: + pid = None + + if pid: + try: + os.kill(pid, 0) + message = "pidfile %s already exist. Daemon already running?\n" + sys.stderr.write(message % self.pidfile) + sys.exit(1) + + except OSError, err: + sys.stderr.write('pidfile %s already exist. But daemon is dead.\n' % self.pidfile) + + # Start the daemon + self.daemonize() + self.run() + + def stop(self): + """ + Stop the daemon + """ + # Get the pid from the pidfile + try: + pf = file(self.pidfile, 'r') + pid = int(pf.read().strip()) + pf.close() + except IOError: + pid = None + + if not pid: + message = "pidfile %s does not exist. Daemon not running?\n" + sys.stderr.write(message % self.pidfile) + return # not an error in a restart + + # Try killing the daemon process + try: + while 1: + os.kill(pid, SIGTERM) + time.sleep(0.1) + except OSError, err: + err = str(err) + if err.find("No such process") > 0: + if os.path.exists(self.pidfile): + os.remove(self.pidfile) + else: + print str(err) + sys.exit(1) + + def restart(self): + """ + Restart the daemon + """ + self.stop() + self.start() + + def run(self): + """ + You should override this method when you subclass Daemon. It will be called after the process has been + daemonized by start() or restart(). + """ + + +class KhweeteurDBusHandler(dbus.service.Object): + + def __init__(self): + dbus.service.Object.__init__(self, dbus.SessionBus(), '/net/khertan/Khweeteur') + self.m_id = 0 + + @dbus.service.signal(dbus_interface='net.khertan.Khweeteur', + signature='') + def refresh_ended(self): + pass + + @dbus.service.signal(dbus_interface='net.khertan.Khweeteur', + signature='us') + def new_tweets(self, count, ttype): + logging.debug('New tweet notification ttype : %s (%s)' % (ttype,str(type(ttype)),)) + if ttype in ('Mentions', 'DMs'): + m_bus = dbus.SystemBus() + m_notify = m_bus.get_object('org.freedesktop.Notifications', + '/org/freedesktop/Notifications') + iface = dbus.Interface(m_notify, 'org.freedesktop.Notifications') + m_id = 0 + + if ttype == 'DMs': + msg = 'New DMs' + elif ttype == 'Mentions': + msg = 'New mentions' + else: + msg = 'New tweets' + try: + self.m_id = iface.Notify('Khweeteur', + self.m_id, + 'khweeteur', + msg, + msg, + ['default', 'call'], + {'category': 'khweeteur-new-tweets', + 'desktop-entry': 'khweeteur', + 'dbus-callback-default': 'net.khertan.khweeteur /net/khertan/khweeteur net.khertan.khweeteur show_now', + 'count': count, + 'amount': count}, + -1) + except: + pass + + +class KhweeteurDaemon(Daemon): + + def run(self): + logging.basicConfig(level=logging.DEBUG, + format='%(asctime)s %(levelname)-8s %(message)s', + datefmt='%a, %d %b %Y %H:%M:%S', + filename='/home/user/.khweeteur.log', + filemode='w') + + self.bus = dbus.SessionBus() + self.bus.add_signal_receiver(self.update, path='/net/khertan/Khweeteur', dbus_interface='net.khertan.Khweeteur', signal_name='require_update') + self.bus.add_signal_receiver(self.post_tweet, path='/net/khertan/Khweeteur', dbus_interface='net.khertan.Khweeteur', signal_name='post_tweet') + self.threads = [] #Here to avoid gc + + #Cache Folder + self.cache_path = os.path.join(os.path.expanduser("~"),\ + '.khweeteur', 'cache') + if not os.path.exists(self.cache_path): + os.makedirs(self.cache_path) + #Post Folder + self.post_path = os.path.join(os.path.expanduser("~"),\ + '.khweeteur', 'topost') + if not os.path.exists(self.post_path): + os.makedirs(self.post_path) + + self.dbus_handler = KhweeteurDBusHandler() + + loop = gobject.MainLoop() + gobject.timeout_add_seconds(1, self.update) + logging.debug('Timer added') + loop.run() + + def post_tweet(self, \ + shorten_url=True,\ + serialize=True,\ + text='',\ + lattitude='', + longitude='', + base_url='', + action='', + tweet_id='', + ): + with open(os.path.join(self.post_path, str(time.time())), 'wb') as fhandle: + post = {'shorten_url': shorten_url, + 'serialize': serialize, + 'text': text, + 'lattitude': lattitude, + 'longitude': longitude, + 'base_url': base_url, + 'action': action, + 'tweet_id': tweet_id,} + logging.debug('%s' % (post.__repr__(),)) + pickle.dump(post, fhandle, pickle.HIGHEST_PROTOCOL) + self.do_posts() + + def get_api(self,account): + api = \ + twitter.Api(username=account['consumer_key'], + password=account['consumer_secret'], + access_token_key=account['token_key'], + access_token_secret=account['token_secret'], + base_url=account['base_url'],) + api.SetUserAgent('Khweeteur') + + return api + + def do_posts(self): + settings = QSettings("Khertan Software", "Khweeteur") + accounts = [] + nb_accounts = settings.beginReadArray('accounts') + for index in range(nb_accounts): + settings.setArrayIndex(index) + accounts.append(dict((key, settings.value(key)) for key in settings.allKeys())) + settings.endArray() + + logging.debug('Number of account : %s' % len(accounts)) + + for item in glob.glob(os.path.join(self.post_path, '*')): + logging.debug('Try to post %s' % (item,)) + try: + with open(item, 'rb') as fhandle: + post = pickle.load(fhandle) + text = post['text'] + if post['shorten_url'] == 1: + urls = re.findall("(?Phttps?://[^\s]+)", text) + if len(urls) > 0: + import bitly + a = bitly.Api(login='pythonbitly', + apikey='R_06871db6b7fd31a4242709acaf1b6648') + + for url in urls: + try: + short_url = a.shorten(url) + text = text.replace(url, short_url) + except: + pass + if post['lattitude'] == '': + post['lattitude'] = None + else: + post['lattitude'] = int(post['lattitude']) + if post['longitude'] == '': + post['longitude'] = None + else: + post['longitude'] = int(post['longitude']) + + #Loop on accounts + for account in accounts: + #Reply + if post['action'] == 'reply': #Reply tweet + if account['base_url'] == post['base_url'] \ + and account['use_for_tweet'] == 'true': + api = self.get_api(account) + if post['serialize'] == 1: + api.PostSerializedUpdates(text, + in_reply_to_status_id=int(post['tweet_id']), + latitude=post['lattitude'], longitude=post['longitude']) + else: + api.PostUpdate(text, + in_reply_to_status_id=int(post['tweet_id']), + latitude=post['lattitude'], longitude=post['longitude']) + logging.debug('Posted reply %s' % (text,)) + elif post['action'] == 'retweet': + #Retweet + if account['base_url'] == post['base_url'] \ + and account['use_for_tweet'] == 'true': + api = self.get_api(account) + api.PostRetweet(tweet_id=int(post['tweet_id'])) + logging.debug('Posted retweet %s' % (post['tweet_id'],)) + elif post['action'] == 'tweet': + #Else "simple" tweet + if account['use_for_tweet'] == 'true': + api = self.get_api(account) + if post['serialize'] == 1: + api.PostSerializedUpdates(text, + latitude=post['lattitude'], longitude=post['longitude']) + else: + api.PostUpdate(text, + latitude=post['lattitude'], longitude=post['longitude']) + logging.debug('Posted %s' % (text,)) + elif post['action'] == 'delete': + if account['base_url'] == post['base_url']: + api = self.get_api(account) + api.DestroyStatus(int(post['tweet_id'])) + path = os.path.join(os.path.expanduser('~'), \ + '.khweeteur', \ + 'cache', \ + 'HomeTimeline', \ + post['tweet_id']) + os.remove(path) + logging.debug('Deleted %s' % (post['tweet_id'],)) + elif post['action'] == 'favorite': + if account['base_url'] == post['base_url']: + api = self.get_api(account) + api.CreateFavorite(int(post['tweet_id'])) + logging.debug('Favorited %s' % (post['tweet_id'],)) + elif post['action'] == 'follow': + if account['base_url'] == post['base_url']: + api = self.get_api(account) + api.CreateFriendship(int(post['tweet_id'])) + logging.debug('Follow %s' % (post['tweet_id'],)) + elif post['action'] == 'unfollow': + if account['base_url'] == post['base_url']: + api = self.get_api(account) + api.DestroyFriendship(int(post['tweet_id'])) + logging.debug('Follow %s' % (post['tweet_id'],)) + else: + logging.error('Unknow action : %s' % post['action']) + + os.remove(item) + + except twitter.TwitterError, err: + if err.message == 'Status is a duplicate': + os.remove(item) + else: + logging.error('Do_posts : %s' % (err.message,)) + except StandardError, err: + logging.error('Do_posts : %s' % (str(err),)) + #Emitting the error will block the other tweet post + #raise #can t post, we will keep the file to do it later + except: + logging.error('Do_posts : Unknow error') + + def post_twitpic(self, file_path, text): + settings = QSettings("Khertan Software", "Khweeteur") + + import twitpic + import oauth2 as oauth + import simplejson + + nb_accounts = settings.beginReadArray('accounts') + for index in range(nb_accounts): + settings.setArrayIndex(index) + if (settings.value('base_url') == SUPPORTED_ACCOUNTS[0]['base_url']) \ + and (settings.value('use_for_tweet') == Qt.CheckState): + api = twitter.Api(username=settings.value('consumer_key'), + password=settings.value('consumer_secret'), + access_token_key=settings.value('token_key'), + access_token_secret=settings.value('token_secret'), + base_url=SUPPORTED_ACCOUNTS[0]['base_url']) + twitpic_client = twitpic.TwitPicOAuthClient( + consumer_key=settings.value('consumer_key'), + consumer_secret=settings.value('consumer_secret'), + access_token=api._oauth_token.to_string(), + service_key='f9b7357e0dc5473df5f141145e4dceb0') + + params = {} + params['media'] = 'file://' + file_path + params['message'] = text + response = twitpic_client.create('upload', params) + + if 'url' in response: + self.post(text=url) + + settings.endArray() + + def update(self, option=None): + settings = QSettings("Khertan Software", "Khweeteur") + logging.debug('Setting loaded') + settings.sync() + + #Verify the default interval + if not settings.contains('refresh_interval'): + refresh_interval = 600 + else: + refresh_interval = int(settings.value('refresh_interval')) * 60 + if refresh_interval < 600: + refresh_interval = 600 + logging.debug('refresh interval loaded') + + self.do_posts() + self.retrieve() + gobject.timeout_add_seconds(refresh_interval, self.update) + return False + + def retrieve(self, options=None): + settings = QSettings("Khertan Software", "Khweeteur") + logging.debug('Setting loaded') + try: + #Re read the settings + settings.sync() + logging.debug('Setting synced') + + #Cleaning old thread reference for keep for gc + for thread in self.threads: + if not thread.isAlive(): + self.threads.remove(thread) + logging.debug('Removed a thread') + + #Remove old tweets in cache according to history prefs + try: + keep = int(settings.value('tweetHistory')) + except: + keep = 60 + + for root, folders, files in os.walk(self.cache_path): + for folder in folders: + statuses = [] + uids = glob.glob(os.path.join(root, folder, '*')) + for uid in uids: + uid = os.path.basename(uid) + try: + pkl_file = open(os.path.join(root, folder, uid), 'rb') + status = pickle.load(pkl_file) + pkl_file.close() + statuses.append(status) + except StandardError, err: + logging.debug('Error in cache cleaning: %s,%s' % (err, os.path.join(root, uid))) + statuses.sort(key=lambda status: status.created_at_in_seconds, reverse=True) + for status in statuses[keep:]: + try: + os.remove(os.path.join(root, folder, str(status.id))) + except StandardError, err: + logging.debug('Cannot remove : %s : %s' % (str(status.id), str(err))) + + nb_searches = settings.beginReadArray('searches') + searches = [] + for index in range(nb_searches): + settings.setArrayIndex(index) + searches.append(settings.value('terms')) + settings.endArray() + + nb_accounts = settings.beginReadArray('accounts') + logging.info('Found %s account' % (str(nb_accounts),)) + for index in range(nb_accounts): + settings.setArrayIndex(index) + #Worker + try: + self.threads.append(KhweeteurRefreshWorker(\ + settings.value('base_url'), + settings.value('consumer_key'), + settings.value('consumer_secret'), + settings.value('token_key'), + settings.value('token_secret'), + 'HomeTimeline', self.dbus_handler)) + except Exception, err: + logging.error('Timeline : %s' % str(err)) + + try: + self.threads.append(KhweeteurRefreshWorker(\ + settings.value('base_url'), + settings.value('consumer_key'), + settings.value('consumer_secret'), + settings.value('token_key'), + settings.value('token_secret'), + 'Mentions', self.dbus_handler)) + except Exception, err: + logging.error('Mentions : %s' % str(err)) + + try: + self.threads.append(KhweeteurRefreshWorker(\ + settings.value('base_url'), + settings.value('consumer_key'), + settings.value('consumer_secret'), + settings.value('token_key'), + settings.value('token_secret'), + 'DMs', self.dbus_handler)) + except Exception, err: + logging.error('DMs : %s' % str(err)) + + #Start searches thread + for terms in searches: + try: + self.threads.append(KhweeteurRefreshWorker(\ + settings.value('base_url'), + settings.value('consumer_key'), + settings.value('consumer_secret'), + settings.value('token_key'), + settings.value('token_secret'), + 'Search:'+terms, self.dbus_handler)) + except Exception, err: + logging.error('Search %s: %s' % (terms,str(err))) + + try: + for idx, thread in enumerate(self.threads): + logging.debug('Try to run Thread : %s' % str(thread)) + try: + self.threads[idx].start() + except RuntimeError, err: + logging.debug('Attempt to start a thread already running : %s' % (str(err),)) + except: + logging.error('Running Thread error') + + settings.endArray() + + while any([thread.isAlive() for thread in self.threads]): + time.sleep(1) + + self.dbus_handler.refresh_ended() + + logging.debug('Finished loop') + + except StandardError, err: + logging.exception(str(err)) + logging.debug(str(err)) + + +if __name__ == "__main__": + install_excepthook(__version__) + daemon = KhweeteurDaemon('/var/run/khweeteurd/khweeteurd.pid') + if len(sys.argv) == 2: + if 'start' == sys.argv[1]: + daemon.start() + elif 'stop' == sys.argv[1]: + daemon.stop() + elif 'restart' == sys.argv[1]: + daemon.restart() + else: + print "Unknown command" + sys.exit(2) + sys.exit(0) + else: + print "usage: %s start|stop|restart" % sys.argv[0] + sys.exit(2) diff --git a/khweeteur/icons/favorite.png b/khweeteur/icons/favorite.png new file mode 100644 index 0000000000000000000000000000000000000000..9296afff6a63049567aa424e30253e07b675e9be GIT binary patch literal 1341 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 aIV&(SIxsN8$8;nB00000P6eP z2wrkU{|czGeD_h1WywV;5>LVd(3Zb$C3KBEhtR!TodI`Pe@dPhhq-x?062{RfSCcn zce%y<3_!g6mgx`xu%!TOqE&W!I?4}}j}cu7AobTYc%EM?cT{L@esloV!F~k;`p8lKJ_6qR_{6MVaJ04ye06A0IzAhFSF(`{$t+gY>1cawTI&l- zM6(V&JD!KQ;t4bLtg|&k{Mo1##zLr;yJf6ZsC}3Y&GfR+vm~H3IfyLf&CNW>>f^<$ z#sT>gLz4n&(9-!XNzj?OWBdqd0yQBbh5zNv*+14aklp`T=TI>*F%GHk9J^QFEl(A= zkrS@lt8!bmzp54f=>DOhp#2uEG$Mc1TBeQACx>63Ln^#7TnBr z0rH%}a8e;X#Q;o8B!R6Da#S4kCvslUBNon&?3Ai2DfsUQ(||p3ck#UWZE=wsaGPeL z$Rt}OU2BNi`6FdDGKHU7Gcx(Al{4L7;luT?R;#;go8$gA`!~bLoj1SuVor*i4V>9) z&9vCi+_t@~V_jR|s6YXHp&EDQrn9@um?;E?3OBZuc}y5(6FTnDCnruTbS2E*f5|&} zX^U%j08BM=m$oyi(ChvS_VH>C$6r2-BvVu72XfEHn!0}r=I`1zTq)~+1h?pGxzx|n z$Pz%a1l7gG#lI=0^&p02DD!ac!%6SM<84#Z!lpNfdTj)Ti7_tI|0EEUJaZna(N3## z%{SSQtz;i9nqzS#mTIM;tVn>A!1G;O8e3YD3DDY?glu4*z4c;0>*7F0Ae&8l-v!%C zA`LZ_H^eBR1&c|hQOOk~cUDKr#X{qZIaUv8sjUXna*Bv@B8b|)Xm2k3iA?LOv@Q{zi%3&kUP@=*h#aY&?(FPj+zom|2XWVKW<$+dA_9p~Yb;*i zNoA1r=3DccGWvau_1A}y4#5^@mpi)K+vjmEo<}tIRWnEf@ih~nTANb%{I_tL3zHpE zq!(~l*n6;X#lVCRMv2jyQJ+ZA-HVE^B03t5tcb>r+t)q<7?w3 z&(koyyI*uChaw+1IiC`?tE&7yQKnV?^ea;!)e>3EsLJNm;qE0+oy%vAf&gfcs)L zKWEeSpU;KD_(nc|)gPon;0F0ja8Na57xM)81Ud=}3#TgVsT3}UQ(krLdCk>QOv1;$ zlHlaId&|L!w^iS20G#|O4d`#k9E7_m+*W(5&rAhM5G6;$1H3E9Q(bcd`2uY11-dP3 z=Vr};fq~zFLn!3u+MWw82+3SD?{TB;Trk+iygVim?wG9OLPcinpU3ia)av4(JtKhY z=ewcr{jv<&qo;Wbp;@`$0NT^7llOIn2uj7eX-^28GfszvlH%SlN@BiGUPIxc=z4bS zZnv=GfS={(^#nQfv08ZJBy*KfBLwJ0=%4 zu5FYZY(Y?`tp65^#M;Epf9y93YZF013Gb(u*zNTpJvoG1vZENeC9 z%;AR7ni7TNg;U$Q?J*Tgw*Dg z+QpRDu+Pn(sKy|4!`hD9ykty0t*3xU2qE;y8W)qEkc~+ufZj|U`s8bG8Y=BfRdmn@ z(G)9B4>VBl=+f{^N}A8XO8Zv{iMdD3j-dme^Y(1yxQIc};Hp&9gXkeO#%(<`zq@D^ zT${X5g0wXM_0evPbMcKMhsK3t!(zkb0<6#&v?T^(iLv&^*kI8%Sj-L!GzN=CkKzlT r{vQxSB}YXh{SQD}T4TI1_E-!WYi<7zfFGmv$^o#?Wj}%I9K!k!al?m8 literal 0 HcmV?d00001 diff --git a/khweeteur/icons/geoloc.png b/khweeteur/icons/geoloc.png new file mode 100644 index 0000000000000000000000000000000000000000..88bd9fbddebd05621662b0b14ee03900300d43ee GIT binary patch literal 292 zcmeAS@N?(olHy`uVBq!ia0vp^d?3uh1|;P@bT0xa#^NA%Cx&(BWL^R}Ea{HEjtmSN z`?>!lvI6;>1s;*bKn;gMnDKc2iWH!rWQl7;NpOBzNqJ&XDnmeGW?qS&pKFLhnX#U! zp84{Yxo3cirg*wIhFJI~CrF4m>??6^NP4-B+bebV|K<#V`3nwJpnisagw9a27qHzJhZZ!;3BK7cSH~Y*Cxw^m9@yt3t2BER)`!uUQXB fb$*oARAXkCu3wgEb2wZa=spHdS3j3^P6Px#24YJ`L;!aHcK~-SZXN0X000SaNLh0L04^5*04^5+Tx5wl00007bV*G`2ipP? z2nrn30B=PA01Ki?L_t(|+U;6vY!ufO{_f1|!|TW52gKnOAi*G#Kt)oNv=m2eW2vQW zh#_ecL#j$q@&ghmQWavQQ6*>t5)na=7D{8eIZ&LAn`}RGW6UZ4Z z7?iNA<_i}teEiZ&FLmB#j=Khe0H6c#05FanJN7r9-&YxpM*p&K;ljTjJ#y3l5IBDP zc&*pt8O=g~iCWFLxp@2b?Q7p&{;;g!d-v}3B+2LV`WCNzXJ!4W)vGE16t%avH%TIplwjr} zpU1o6x!*j0`p}_6Xvs_Ou}rKP2LH8nM_k1ZR!SJ(B&NfL+{!~~KAk}{wP?RVoR zj6YphSNF58tDhJk05kwT0C@lk0Oao7yZ7zF!or_8of;b(_e&|g!-o(5;jP0NmSr7i zX=zzBefsqO0x&8oD+e!FuwYXl5GYUeNl_yAv0&r zTqC6{ky08tIXNSIe*X}W77)6*Ju_Gc=NFDfBU|d~>i#}$TKT(vzkfoHL%X>~M4#5y z)=sIet`7AEgcu+<@BzqOxNzb3D=RBE8@ds6FB%I#FatuQDQ};1vLizP%<3UU#?!LP z3Pf6(vAn$eWdK%BCZi7k2!;c~(W6Ht5nq}(Y2uHC27?(O5WoO|0b+Z3 z*!fRjW-tRp42FGds|7FlmV zxy_q5H{4BPWY^sKrcE0^e{a*KRS8R4L=r>sJTReaM{FauMn{N@Z8b=~OKkvO=AAb1Dp7N1_0>J`csuo+n zVZ(p6y#M}s%ObKFU4k6|h#}clTq?F_slZ|hmI@R}>Y4){ffcAs43Z4pFc^SqE(IjP zm!F@%ZBI?j8*A6BDar&w$H0pYz<@#LdFO!mhG$WfQ?HSQ$B!ZOUopt{PbNQ75*m zq>jlsPr48p<>lqa1_gtE%Fi!<HO6%(|}YPZXDwyfS0PjICR@ZfzUrWJyzrQUJgp z0D}Px0Z_bX(W2>3Ry_Gk8bt}AoQTuHWX3=Bi%<;FH!)4~Y9f*NUm}q>Fl*MVmC9NP zio|+~7<>m@BAS`!FI~Fy-#(wuH+AaNUk&V*CH451FJIm}ckbN9@p!x$KuabK=-nha z`mSBOUax$*GPG^`wwrf=A4oMPmgxr%9=uXkR^}f%bm#-j3?@?Uz_&C?ntLxT8A)z! z&CSiH=gytGI2Mbw0|*0%0f@VvDT#=1g*R^~6uKD5%RRYs=gvP@S6AoulRzB+EOp~f z0PTT5pljZ|c@syD9682jcLK3ZbW)v5lF4IIr05VGCI8FR@2-|PcF&$Y>mrfJbpWjj z1k(+G+w|Q@j^F3=dADxcw$Ct(2~6}^G#aUX_Ss+mD|7JI-0tcCyko|U3C^EC|F^@3 zkC^B8dxwGrSg+DY;kz01I`_DgE6mt?+QHfXNAol+3W5^jHW1P17KR4VGXa+|_lys;X)R zfExh103>>&5?p2}+?$*pXhNhZ z*nRj)dyu3z*vmRPS5^>q=LCJ- zKxqIxkx0a-si`?JdGh4qU@$o9)-Z(L!&g6>loF+!(n^QA&%azmx8n-~OV z9LbFvH@y7C7hhae03>cl=6VmL?DidY6W+H(&*^w6N9qQcRn%(VA7O}M2Wr+nL; z2D4HEE&!sj7&N_Zs@&(rZ^)WkK?0{4lmoWW5<3)hz=R{8H8?=9A?({@85rZ z^yo20X=!O$D$h>=Kujs6m7gY)9S0~!Sy%q=00IR-JRV1TC)`SRt}MAQhN zU1f#xn}CoBggd@!Cr_UI((m)P-gn=q2Q{G?cAbjy5qgZd1q`p>`*adEXzWqD}tD5A{Y$z!UHyFmSv&2 zx%v8T{q}UJ;5PKnVE9lxN##=TU&dgxTM6qx3u&DO$eRRRxM@5 zsTD<%7u^0WB_W0f0OwuI+gAt8J(b(Ad)%x}8e}C=TwF`;|oM=FI z8iY)Xuw0FpnNFQLb@}Ysv&RdIibOCN94d6pNMAKaaoAefk#IE&fYsL47OAhVKVM&8 zf4HEaAUbH!pnDC&@b@Jzh(sfZL?Q?V3(}b1$q5pP1VZg0G&MDSv~JzHH$$P&rz#=p zR77Z@A9WDXkN)nrOE~~?)eDvam2&3+2>5)yz*A2>_1GhiJo3|mg8X6WWmk=%YOF!EGO zf&su^QBg5z^5n^n-E+@9<2_z)o^akXIO#D1(n_GCqZ6&Itr$9N7~-ahSS*IP8K=&0 z$Cp>X{HpHYfdjicJ3Ftc)pu`?`whT1tucxWFuV#3$2uG!aupapP1C$nrc4?0@WT&3 zSX@#(41n-@y#X+UuIqttxJwJQw{f^L91e%Wjm^!?pPg%HIMdnDaa}Qgn^L|*B}4so zfjj*MDiaKkvW^_3e6DIa3M8-EsA(DR0h|q-u2$AmE8V3`zC-=*Qh7kXNN}KE!=-(M z;d&G0xQo+#E@ich(w#dLJMS%`O6jONkM~p@`7V8=A-b^+Ls^K^y0Zjir0P~i1WLGm zr#t;Lxm8@hXD?Q8q0e3xRRb3CT5C8xG07*qoM6N<$f>P11Z~y=R literal 0 HcmV?d00001 diff --git a/khweeteur/icons/reply.png b/khweeteur/icons/reply.png new file mode 100644 index 0000000000000000000000000000000000000000..5ba48c7461e1269de30c2759b1ec07a7d86ca6e2 GIT binary patch literal 1156 zcmbVMO=#0l9DieOj=AXs4{jcesUpIfyqA7t%~rQI>0FuJMpszD%hKev4P8D=-fTBe zHcxsGKk(qe!ww!r6g;WuL=imbQAAYyLIe*UL{Jpw%eGDrV}gO?z5jdv-|zpO(_Bm|Ktg#-ygX7ypIHCHc6S!Hn57M0>`nGi?f`D(Sw zRYRQZmUtA4#e5Dy2vUupH)9dC7PP!Tok78#<{Az$Y>V+3)q*`u;w<%aB?QySWY&l+ zZ#7XgWqeI_c*H^8H2t`iwLOx>f81DC+sn;3n9pL*o^~}_k7A$>rn%eLkgrI+kw#sE z7Db&>Y;D@a7D+2{mfmo>p-T|;K!n6F6a^UVK`0SM2{|Ro$sSn<%b{qUV?AtAi79eO zjD}-!451WE2!rCFAR{@1lF4W?RL7<*kEoW0>vj!lcNH7i5G%6Gp1cz7ZQQgIG~X{vcaKcG P^#2R#)QEB|IeGFoa~y7W literal 0 HcmV?d00001 diff --git a/khweeteur/icons/retweet.png b/khweeteur/icons/retweet.png new file mode 100644 index 0000000000000000000000000000000000000000..bbfc43d22044b8e8d31be815a6ab3679fb781c7e GIT binary patch literal 1093 zcmbVLO=#0l9FGn}#wLn*P(&XY;>9-arCqazWg|^mSFm%mg-xfjEO~9?mb{p}*={PL zf;td(5kHs%y@((RBJ3jQJP7mVNthsr;6ZTgpobjNHA0(c&G8tT<2t)*o?(t)3Wjmnb`^f<#Va1zmcsY9=U~p$aK-MK z@Nr?Hw`fj`m`RK8?gE`PnGrac8ldKkx`A9%_!VB6?Zaz<2P-CYMB(dB4dnWOMtls~ zqcCE^1Oy2bMX|UfNr!+45fq>(ATfd@IVQ>wg4Ks-YQ9yH^Ll1gi=7m{La8SULbY0r zR%21(mj#qeCPNNUj4+ExFz!;L7IA~N27`_R)3-fp6BmSx#xNP93eP;Phv0a*+#0bP ztR{-3OsE;2fTB=voG`8x>wxC*A2-&u4vOO*7V|S?)F#3JsYz@=@Pr zMKMNoVvaf3rCD9!*`KIoTQWo)5FsfJB@re%5K`kv)zXrd>d?fv7E3fZ*2BhisK(QA zF`m?t2&JJaW~7X$AuWbdsYEK)z-HZm8m@^Ox;E2Y#i9+dvgTuh62C~uXrlxADuj}t zLOh@q+Ck1RZ8toJ<*7%jW8XfDt&C3`u##WdUPu4mao4fd|Kvv&2yk7_IYq|`p5H`MdRkXXP0iAymjTy z`-cb8k4`VBk?G}A_ZK$JWtTp--VJ#F>X-g)W{Yz0`qZ(Cx^u_W>si3xp5e>JV`pLh z#PZ9@>QJod+PS&G`SYLdy?HX*wYcxXWcBC82YWZSaMJOfU0-2@4+lG&?$w{BhA#gG DqaR=b literal 0 HcmV?d00001 diff --git a/khweeteur/icons/tasklaunch_sms_chat.png b/khweeteur/icons/tasklaunch_sms_chat.png new file mode 100644 index 0000000000000000000000000000000000000000..06b5dbc1814dc6402ed869d0005d0053b4b5213a GIT binary patch literal 6988 zcmZ{IbyU<()c1Gk5*7qpX$e7kX_l1kMmhvZ>24Mfq(n+WxL|)U|VzIKG-bI{bC@CCC?tWo?;7KxdI?ks`2mvrW+XqO$Em*e2^RGf*6mGp~~Q z8jb+HQzAQw1ZYJCIBA%@(eZTXLeSxoX&m@bXP?o4<(uQ9{m0QOG>qk8$H!2eW-uaF zgl>a1o(-(y0gME|x0s&4Qa~XX;Bh14!322lfc-*`bToho0E}oTpAo_BL4b%=qB=U@ zHO6>}2Gp}?v!Y2B0Fo~UNTYy1gn)i(0ci|SWe%_mBt*J{sxkoc#Jr(o!2A$^XT2hS zhYp4S=J{ZJZ4k}{&>1f;dj#O{f>>gcD}BcFj9-js1He(uyO@o{WF1VW7e9KajZK<- za)kt?%QR*_;C|RaYr@&GvKqh`>jjc{(E+%emkgX}9CvVB+LNq5#%qh$bVKb4cNY8%e9j|QxJFNKzjg0TQ7R#=iZ5x|Ygc%Zg@4&?q#B&7o2&xphE zznoepNjr_!HM4MhF7loTb;9J~y&gN9F!8|`@^dki<;A!+zkqkd&tzQHw zaFc}eyogf3qnAzorS*Ruw_m^oQ4T?rME>o8Ch1f5#LZat#LDJ&6kO9fR-)Zb4 zX7h|ED4$3b{6X2`HRBA++!UNfRD?FSNyuOCY6Sj%-G5EUg#QcAjo4QN1xE3qV7~C%IH_WY2Gy(kWS{6{ zEyOyX|C+(m~E-;mmhMo zzO&M^Y_KdR8SkQx<4k8?&Xlg-Ud#H?-ib+vz3p9wl0cM@SjZscbEFuO?PZc3^2`Fs zOE&am$Zu14({fXUEG4=e&rG(kMyklL$Y7&+0ACk@C_yyrqD=-&;!PGH^mOGXzS~>d zi}Huey4Bg&t=W4|ASVoV33u~$(|3z@u_w7oi0tC|hxl&!WGKQZZumy*&T6?+pfP9Z2Rfhi!_hm1mZ!TmBY&rTEIrSHOSDSIVz$f8oX*_x$7U{^exXI2R{( zah1`GRWVsntGi_6tLfs_$*+iwBBm+UDf_YJqVw03(--4v(~E@*`F9E*b1jRWRwR|y zDsQ!p{L;D5xh(lv(yRShrL8h%*KkK}n{#xbc&dqMUCX zvJc7kRiHuhl#m|tm@d08o5q(1pKO-D_+X3Me}50L{mwW4e#TMA>75hb^!Lv1{o4u9 z;k5Ue4z0b73qBJ)os2#2N)*HT1GN#aruim4GVF?XjCkE^A+>eZ9M;mChMNJW0jF4} zAIVkl)njDI`N;)26jO``JqEK$H}A5cDU|_8Us&cxX2Ljz*3K?QeVv@AifOj^abtU?FnW^vJ8rf#P(`&GI1dBiKd&B03^%)r9)opoEW zO1Db1kptsNI(3ck`U=&!@R%@19(aIgf$ExNNYp`d=JQJ7QPt7zQ86(gu`&aU!5{&j zfk>`B>LiLa#*cg2qQcF||A)PYltQo%)uMX6D2DIjR^RvF)e{2jYclZI!W`O2t_g@wv$SI_5Ajn|*d*`soPFxct* zdg}C4{F!f5ePl#0&8pk6d5yfA?}`Niq7we=m}z6buA{}bn>)%k$;YUw%7M( zQL$;U-&B;WEjC?CBKS;Z6N+-aq)sx_rjYgfJ!|5aZMM~+VXPdKtJ$(?V4cBB&sVm~ zGcMR4O#h;tb;>CSyR^;r%jT)ciLehrB81>);{5~gD46dY)$=V634D? zxY+*FM|LGTHtGRY6@ZW*w z2hT*$g}vg+rS^OG>hD)>QGtrG{e4<*cQ%}<+DmS&Eu>E(4tTa|Oehs8izuC)Pv$*x zjoU;!T7%txZEkX0rL1yPHgL}Sl~~C29ytDR9B^#=()Fc$PPN{7{=S;EZud={y5~6I z0`;DE>%yy9-@w%A3gm)ci?Ubn48vb{Z|Ba%O^VvD(Se15!Z+Dda?@TxgLn1qYQ+_W z2jf9I_lxMn*sxub-94gR8us^+l*v!JKavod+>BhdOvz;++f*~ZC3d^$`(FLMj9OX} zC-Eq}W4W|lBl;$>FNB1>nUCe;a-wlmblh?L^!}f)_>GT$hW}hOtz+a28wUr_Tt|^8}an>&Tij!J?>#@ zQ10V$=V~x^Z6BN8nDE?ZF3ySb6!8``^K6>+8$LHQ`aP7bJuOz5)3)Nj_EYtKn5|)R zez!^N@?t>**W2$f`haj@u<`OFXyon~n~j!)mRv&1U*}l=(slTEiiF@f>@s`*^xvKz z32OayN7VyLqUPb)HTUS%saAvc5owB3qc$?y}1F0As3%IfCBI;H-# z4jQ18y+&~4mLE*?vHQW;*tje?C53a(?yo{3sA|5=zkyytBDjT7sfdsHbFv9ZHO6Vb z1EZwHFA}h!i&tDQPwKnR2n1|2xZy#MQqV~!1-~JUNueSxj!<4#SJ%|l^%$l}%8}^~ z*0gX@x*zU}>J*kA@XXXY==XE6gzOOuF?SjZlH#3vd3aF4j#fiPE*FEg6Qb#&dtqL@(T*)G`ejliNI+9Qy`(%<~JT^s9Cooy*N+`eVoa^_~HMwq@MvdusiorP#lwxfxd3 zX|ik)i>queof7smgr~wPmz?k;!`4Vv%hjknZPxbwek-OqPA3L@8+E$1m#Ma;E~^;; zg0n#g&f8B0%)a-aN=+C-4H5z#z{E&;a}Z z>vV;Q(cSF(O2_y1_J%o0NtUV#W4Dalr?{`OMf}NL51=BetE-QbnAPtut1bJ2C<(Cq zK?G=!xmugb>x(((LrxZ#4mO;h*6ty=s*|QmV?Q>`jEszq*OSyaD?k7SaIuvWd@rnL zx=0kIz%%-rOxAU9?X$!|zQ?uR#MOFwW!vZMU;@ z$d)K4DvC(_D%a&OTg(~eM(qr+WOqa$M&j!0>grmLe&Vkb1pw&gm{s;S7ysr&Y1E$* zfFAx_m&Z+*QCeCm*mZQZvn9w3?&45fjhme{Y^txn zK>n?@X#;eCfPetwf7{zQ>zlCfnwl{wgOm-K+@1u@;VtI?2M34!tK+rexWMb3^kt7D zLU@otN+4IqZOzd^%b~cLzQ&PWK8w=}`WGwCJhA%h0)MGOPP(;X2{qjm>J)aB0AiCgaG(JkV;9wcrpCzC&mg2!K!{f zS|Gm^v{&BXbaQua08}gRk=#sj1wY^Ww}VS1>f_>H)ThmgIL=pBT)Z{%g-x&G zV7`ubE6aP)*Xw+DveZ$)D1i=#6PY$VF*b1*|AJ3-JP%Q;L5c}w!cv`H{b_u5l;w@rJ<`^hYOD{8s-5uyjlYH#?>YM(LR6P1m)x7LziTte@u*)pT9rzppr7RQ^On+ zPCi6}6&G-g(`P3?_iMZJY7wik`JF9cJ2O#Ll$*P(shBtjtP>}EiY#ohoSdNM8RJA^ zMCRkf^rB@9W0el$DJFIb=S0fTKTOWf?#@+PVm~1y*n*kOUQf#i1e7!Xx#+eqMInTssbdZr*@jgXso&_Ptu%@2=0Au!(4>{x&%0TrPKod7RnW z>p&WaK=7xX49@MSO*Rp4@pDV0%nm5g7p$o;{=@8Y;onW4_hQhNI9vA=VQl!6NW(lgkr`ru~E{)O)w8af2xb4)(^!WZcnuzQ3D?YNo6cqmJhxt z$ohS^+;q6P)gqQD)QHr6Bj94Z`@94oHfn9{g|J~CVcD?fmh2WI=xmQaA@yp}SZwFV zW;6^mg`Z)wlYHYe#O9{biDK+mpPZZ=cGB-y*~9N>S@vK1B#5{Wt3CW}!l}m*lJ>Tn z3T`)k9Q!8#Ye*%&nQFX97_`h(AnfldOj^{qw$aJU#~2Yw?kKTzc!i(1FN&RE!qYWAh zO}X7yELqJ?Pw5u&FC*y0%BHVk!XX|em9f1RrR3sRm{^8ERPWFvapP7=KKEK^=<-to z0?*pG>*2p@mUEcoU#Iy8Z4N%y(XG3_dwSu)tS~=>NUW{LW?F-c>5w51QsaEv%_(9@ zuG*2OUxsY!@qq|MuyVRqvc8M$)x87O6K)bRxuL2)?5#!P-$$2Z%Lhheso zZ}yEJHtey{EsuNpKa4m?Mo95?i!CgSr>jbicVVO?yNY1|Jx{6gCF$U4`I7beDcxaR zI82iyHGM=cgCbk>v9Q!cH!p8)4(6MZ2;txCn!mkgWSmAPu{zk$7Ck3}bnDQ;OLpu? z$;m1D^v95!LcAFjo9;;#eqkZLP-m?H_&bgq+EU2NhosuDJimLzB`WdV8{0^lmrx58I0E4i5I|L1Tk8VL z0?R^2Wma5Yjo%KrWXploT`OKO?0jVe9ZAx0&rVHU?5*{EZjdP$6$kPd8xjCIY)L4b zAD9}b*)J(6F{pJ~Xl5;)v>%*WB9gYL% zZT412s{1VyRo=0z?&QqTk;`;%%rkY9_5hTM5LG*FK{_mBRa=DwGy^IN9mO~YSgd1( zF<-~0u6gkg6GM?{k}`z!tci>z-1^J<{I3 zm@#Vm17D8Ub6}m#)zr#Qo`oEO_p+(?CSJ`O5E`LHhYKZln$eMn@hX{9#{gn-a(-p zIDTx*nBeKa9*M`zh2vF#_}lJ?Cwm;^gG78LrCFH-9@njl%8yR+;IE%X=c|B6KlE48 zMoh8`j|IH3{hi?5H6-HE{Z{R$OAp6agBfN6zW#WsHYDZ00p$= zyv(Wpd6o+*KH&R@<2?z0<%B+dATW8z>U+F<>tO}6bhCO?01r2mn*$2vfC}jF@WS|o sVBEZH+)x-dcWFzo(f=}Vbbe=N?fZWl%x?B=JsJRtvT8CFQs$xm4{zvY>i_@% literal 0 HcmV?d00001 diff --git a/khweeteur/list_model.py b/khweeteur/list_model.py new file mode 100644 index 0000000..be4d7a9 --- /dev/null +++ b/khweeteur/list_model.py @@ -0,0 +1,227 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2010 Benoît HERVIER +# Licenced under GPLv3 + +'''A simple Twitter client made with pyqt4 : QModel''' + +#import sip +#sip.setapi('QString', 2) +#sip.setapi('QVariant', 2) + +import time +import pickle +import datetime +import glob +from notifications import KhweeteurNotification +import os + +SCREENNAMEROLE = 20 +REPLYTOSCREENNAMEROLE = 21 +REPLYTEXTROLE = 22 +REPLYIDROLE = 25 +IDROLE = 23 +ORIGINROLE = 24 +TIMESTAMPROLE = 26 +RETWEETOFROLE = 27 +ISMEROLE = 28 +PROTECTEDROLE = 28 +USERIDROLE = 29 + +from PySide.QtCore import QAbstractListModel,QModelIndex, \ + QThread, \ + Qt, \ + QSettings, \ + QObject, \ + Signal + +from PySide.QtGui import QPixmap + +pyqtSignal = Signal + +class KhweetsModel(QAbstractListModel): + + """ListModel : A simple list : Start_At,TweetId, Users Screen_name, Tweet Text, Profile Image""" + + dataChanged = pyqtSignal(QModelIndex,QModelIndex) + + def __init__(self): + QAbstractListModel.__init__(self) + + # Cache the passed data list as a class member. + + self._items = [] + self._uids = [] + + self._avatars = {} + self.now = time.time() + self.call = None + + def setLimit(self, limit): + self.khweets_limit = limit + + def getCacheFolder(self): + return os.path.join(os.path.expanduser("~"), \ + '.khweeteur','cache', \ + os.path.normcase(unicode(self.call.replace('/', \ + '_'))).encode('UTF-8')) + + def rowCount(self, parent=QModelIndex()): + return len(self._items) + + def refreshTimestamp(self): + self.now = time.time() + self.dataChanged.emit(self.createIndex(0, 0), + self.createIndex(0, + len(self._items))) + + def addStatuses(self, uids): + #Optimization + folder_path = self.getCacheFolder() + pickleload = pickle.load + try: + keys = [] + for uid in uids: + try: + pkl_file = open(os.path.join(folder_path, + str(uid)), 'rb') + status = pickleload(pkl_file) + pkl_file.close() + + #Test if status already exists + if status.id not in self._uids: + self._uids.append(status.id) + self._items.append(status) + + except StandardError, e: + print e + + except StandardError, e: + print "We shouldn't got this error here :", e + import traceback + traceback.print_exc() + + self._items.sort() + self._uids.sort() + self.dataChanged.emit(self.createIndex(0, 0), + self.createIndex(0, + len(self._items))) + + def destroyStatus(self, index): + self._items.pop(index.row()) + self.dataChanged.emit(self.createIndex(0, 0), + self.createIndex(0, + len(self._items))) + + + def load(self,call): + + self.now = time.time() + + if self.call != call: + self._items=[] + self.call = call + + try: + folder = self.getCacheFolder() + uids = glob.glob(folder + u'/*') + pickleload = pickle.load + avatar_path = os.path.join(os.path.expanduser("~"), + '.khweeteur','avatars') + for uid in uids: + if uid not in [status.id for status in self._items]: + pkl_file = open(os.path.join(folder, + str(uid)), 'rb') + status = pickleload(pkl_file) + pkl_file.close() + + #Test if status already exists + if status not in self._items: + self._uids.append(status.id) + self._items.append(status) + if hasattr(status, 'user'): + profile_image = os.path.basename(status.user.profile_image_url.replace('/' + , '_')) + else: + profile_image = '/opt/usr/share/icons/hicolor/64x64/hildon/general_default_avatar.png' + + if profile_image not in self._avatars: + try: + self._avatars[status.user.profile_image_url] = QPixmap(os.path.join(avatar_path, + profile_image)) + except: + pass + + self._items.sort(key=lambda status:status.created_at_in_seconds, reverse=True) + + self.dataChanged.emit(self.createIndex(0, 0), + self.createIndex(0, + len(self._items))) + + except StandardError, e: + print 'unSerialize : ', e + + def data(self, index, role=Qt.DisplayRole): + + if role == Qt.DisplayRole: + status = self._items[index.row()] + try: + if status.truncated: + return status.retweeted_status.text + else: + return status.text + except: + return status.text + elif role == SCREENNAMEROLE: + try: + return self._items[index.row()].user.screen_name + except: + return self._items[index.row()].sender_screen_name + elif role == IDROLE: + return self._items[index.row()].id + elif role == REPLYIDROLE: + try: + return self._items[index.row()].in_reply_to_status_id + except: + return None + elif role == REPLYTOSCREENNAMEROLE: + try: + return self._items[index.row()].in_reply_to_screen_name + except: + return None + elif role == REPLYTEXTROLE: + return self._items[index.row()].in_reply_to_status_text + elif role == ORIGINROLE: + return self._items[index.row()].base_url + elif role == RETWEETOFROLE: + try: + return self._items[index.row()].retweeted_status + except: + return None + elif role == ISMEROLE: + try: + return self._items[index.row()].is_me + except: + return False + + elif role == TIMESTAMPROLE: + return self._items[index.row()].GetRelativeCreatedAt(self.now) + + elif role == PROTECTEDROLE: + return self._items[index.row()].user.protected + + elif role == USERIDROLE: + return self._items[index.row()].user.id + + elif role == Qt.DecorationRole: + try: + return self._avatars[self._items[index.row()].user.profile_image_url] + except: + return None + else: + return None + + def wantsUpdate(self): + #QObject.emit(self, SIGNAL('layoutChanged()')) + self.layoutChanged.emit() diff --git a/khweeteur/list_view.py b/khweeteur/list_view.py new file mode 100644 index 0000000..7e344f8 --- /dev/null +++ b/khweeteur/list_view.py @@ -0,0 +1,649 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2010 Benoît HERVIER +# Licenced under GPLv3 + +'''A simple Twitter client made with pyqt4 : QListView''' + +import sip +sip.setapi('QString', 2) +sip.setapi('QVariant', 2) + +SCREENNAMEROLE = 20 +REPLYTOSCREENNAMEROLE = 21 +REPLYTEXTROLE = 22 +REPLYIDROLE = 25 +IDROLE = 23 +ORIGINROLE = 24 +TIMESTAMPROLE = 26 +RETWEETOFROLE = 27 +ISMEROLE = 28 + + +from PySide.QtGui import QStyledItemDelegate, \ + QListView, \ + QColor, \ + QAbstractItemView, \ + QFontMetrics, \ + QFont, \ + QStyle +from PySide.QtCore import Qt, \ + QSize, \ + QSettings + +from settings import KhweeteurPref + +class WhiteCustomDelegate(QStyledItemDelegate): + + '''Delegate to do custom draw of the items''' + + def __init__(self, parent): + '''Initialization''' + + QStyledItemDelegate.__init__(self, parent) + + self.bg_color = QColor('#FFFFFF') + self.bg_alternate_color = QColor('#dddddd') + self.user_color = QColor('#7AB4F5') + self.time_color = QColor('#7AB4F5') + self.replyto_color = QColor('#7AB4F5') + + self.text_color = QColor('#000000') + self.separator_color = QColor('#000000') + + +class DefaultCustomDelegate(QStyledItemDelegate): + + '''Delegate to do custom draw of the items''' + + memoized_size = {} + memoized_width = {} + + def __init__(self, parent): + '''Initialization''' + + QStyledItemDelegate.__init__(self, parent) + self.show_avatar = True + self.show_screenname = True + self.show_timestamp = True + self.show_replyto = True + + self.bg_color = QColor('#000000') + self.bg_alternate_color = QColor('#333333') + self.user_color = QColor('#7AB4F5') + self.time_color = QColor('#7AB4F5') + self.replyto_color = QColor('#7AB4F5') + + self.text_color = QColor('#FFFFFF') + self.separator_color = QColor('#000000') + + self.fm = None + self.minifm = None + + self.normFont = None + self.miniFont = None + + def sizeHint(self, option, index): + '''Custom size calculation of our items''' + + uid = str(index.data(role=IDROLE)) + 'x' + \ + str(option.rect.width()) + try: + return self.memoized_size[uid] + except: + size = QStyledItemDelegate.sizeHint(self, option, index) + tweet = index.data(Qt.DisplayRole) + + # One time is enought sizeHint need to be fast + + if not self.fm: + self.fm = QFontMetrics(option.font) + height = self.fm.boundingRect( + 0, + 0, + option.rect.width() - 75, + 800, + int(Qt.AlignTop) | int(Qt.AlignLeft) + | int(Qt.TextWordWrap), + tweet, + ).height() + 40 + + if self.show_replyto: + reply_name = index.data(role=REPLYTOSCREENNAMEROLE) + reply_text = index.data(role=REPLYTEXTROLE) + if reply_name and reply_text: + + # One time is enought sizeHint need to be fast + + reply = 'In reply to @' + reply_name + ' : ' \ + + reply_text + if not self.minifm: + if not self.miniFont: + self.miniFont = QFont(option.font) + self.miniFont.setPointSizeF(option.font.pointSizeF() + * 0.80) + self.minifm = QFontMetrics(self.miniFont) + height += self.minifm.boundingRect( + 0, + 0, + option.rect.width() - 75, + 800, + int(Qt.AlignTop) | int(Qt.AlignLeft) + | int(Qt.TextWordWrap), + reply, + ).height() + elif reply_name: + reply = 'In reply to @' + reply_name + if not self.minifm: + if not self.miniFont: + self.miniFont = QFont(option.font) + self.miniFont.setPointSizeF(option.font.pointSizeF() + * 0.80) + self.minifm = QFontMetrics(self.miniFont) + height += self.minifm.boundingRect( + 0, + 0, + option.rect.width() - 75, + 800, + int(Qt.AlignTop) | int(Qt.AlignLeft) + | int(Qt.TextWordWrap), + reply, + ).height() + + if height < 70: + height = 70 + + self.memoized_size[uid] = QSize(size.width(), height) + return self.memoized_size[uid] + + def paint( + self, + painter, + option, + index, + ): + '''Paint our tweet''' + +# if not USE_PYSIDE: + (x1, y1, x2, y2) = option.rect.getCoords() +# else: + #Work arround Pyside bug #544 +# y1 = option.rect.y() +# y2 = y1 + option.rect.height() +# x1 = option.rect.x() +# x2 = x1 + option.rect.width() +# + # Ugly hack ? + if y1 < 0 and y2 < 0: + return + + if not self.fm: + self.fm = QFontMetrics(option.font) + + model = index.model() + tweet = index.data(Qt.DisplayRole) + is_me = index.data(ISMEROLE) + + # Instantiate font only one time ! + + if not self.normFont: + self.normFont = QFont(option.font) + self.miniFont = QFont(option.font) + self.miniFont.setPointSizeF(option.font.pointSizeF() * 0.80) + + painter.save() + + # Draw alternate ? + + if index.row() % 2 == 0: + painter.fillRect(option.rect, self.bg_color) + else: + painter.fillRect(option.rect, self.bg_alternate_color) + + # highlight selected items + + if option.state & QStyle.State_Selected: + painter.fillRect(option.rect, option.palette.highlight()) + + # Draw icon + + if self.show_avatar: + icon = index.data(Qt.DecorationRole) + if icon != None: + if is_me: + painter.drawPixmap(x2 -60, y1 + 10, 50, 50, icon) + else: + painter.drawPixmap(x1 + 10, y1 + 10, 50, 50, icon) + + # Draw tweet + + painter.setPen(self.text_color) + if is_me: + new_rect = \ + painter.drawText(option.rect.adjusted(4, 5, -70, 0), int(Qt.AlignTop) + | int(Qt.AlignRight) + | int(Qt.TextWordWrap), tweet) + else: + new_rect = \ + painter.drawText(option.rect.adjusted(int(self.show_avatar) + * 70, 5, -4, 0), int(Qt.AlignTop) + | int(Qt.AlignLeft) + | int(Qt.TextWordWrap), tweet) + + # Draw Timeline + + if self.show_timestamp: + time = index.data(role=TIMESTAMPROLE) + painter.setFont(self.miniFont) + painter.setPen(self.time_color) + if is_me: + painter.drawText(option.rect.adjusted(4, 10, -80, -9), + int(Qt.AlignBottom) | int(Qt.AlignRight), + time) + else: + painter.drawText(option.rect.adjusted(70, 10, -10, -9), + int(Qt.AlignBottom) | int(Qt.AlignRight), + time) + + # Draw screenname + + if self.show_screenname: + screenname = index.data(SCREENNAMEROLE) + retweet_of = index.data(RETWEETOFROLE) + if retweet_of: + screenname = '%s : Retweet of %s' % (screenname, retweet_of.user.screen_name) + painter.setFont(self.miniFont) + painter.setPen(self.user_color) + if is_me: + painter.drawText(option.rect.adjusted(4, 10, -70, -9), + int(Qt.AlignBottom) | int(Qt.AlignLeft), + screenname) + else: + painter.drawText(option.rect.adjusted(70, 10, -10, -9), + int(Qt.AlignBottom) | int(Qt.AlignLeft), + screenname) + + # Draw reply + + if self.show_replyto: + reply_name = index.data(role=REPLYTOSCREENNAMEROLE) + reply_text = index.data(role=REPLYTEXTROLE) + if reply_name and reply_text: + reply = 'In reply to ' + reply_name + ' : ' \ + + reply_text + painter.setFont(self.miniFont) + painter.setPen(self.replyto_color) + if is_me: + new_rect = \ + painter.drawText(option.rect.adjusted(4, new_rect.height() + 5, -70, 0), + int(Qt.AlignTop) | int(Qt.AlignLeft) + | int(Qt.TextWordWrap), reply) + else: + new_rect = \ + painter.drawText(option.rect.adjusted(int(self.show_avatar) + * 70, new_rect.height() + 5, -4, 0), + int(Qt.AlignTop) | int(Qt.AlignLeft) + | int(Qt.TextWordWrap), reply) + elif reply_name: + reply = 'In reply to ' + reply_name + painter.setFont(self.miniFont) + painter.setPen(self.replyto_color) + if is_me: + new_rect = \ + painter.drawText(option.rect.adjusted(4, new_rect.height() + 5, -70, 0), + int(Qt.AlignTop) | int(Qt.AlignLeft) + | int(Qt.TextWordWrap), reply) + else: + new_rect = \ + painter.drawText(option.rect.adjusted(int(self.show_avatar) + * 70, new_rect.height() + 5, -4, 0), + int(Qt.AlignTop) | int(Qt.AlignLeft) + | int(Qt.TextWordWrap), reply) + + # Draw line + + painter.setPen(self.separator_color) + painter.drawLine(x1, y2, x2, y2) + + painter.restore() + + +class MiniDefaultCustomDelegate(QStyledItemDelegate): + + '''Delegate to do custom draw of the items''' + + memoized_size = {} + memoized_width = {} + + def __init__(self, parent): + '''Initialization''' + + QStyledItemDelegate.__init__(self, parent) + self.show_avatar = True + self.show_screenname = True + self.show_timestamp = True + self.show_replyto = True + + self.bg_color = QColor('#000000') + self.bg_alternate_color = QColor('#333333') + self.user_color = QColor('#7AB4F5') + self.time_color = QColor('#7AB4F5') + self.replyto_color = QColor('#7AB4F5') + + self.text_color = QColor('#FFFFFF') + self.separator_color = QColor('#000000') + + self.fm = None + self.minifm = None + + self.normFont = None + self.miniFont = None + + def sizeHint(self, option, index): + '''Custom size calculation of our items''' + + uid = str(index.data(role=IDROLE)) + 'x' + \ + str(option.rect.width()) + try: + return self.memoized_size[uid] + except: + size = QStyledItemDelegate.sizeHint(self, option, index) + tweet = index.data(Qt.DisplayRole) + + # One time is enought sizeHint need to be fast + + if not self.fm: + self.font = QFont(option.font) + self.font.setPointSizeF(option.font.pointSizeF() + * 0.80) + self.fm = QFontMetrics(self.font) + + height = self.fm.boundingRect( + 0, + 0, + option.rect.width() - 75, + 800, + int(Qt.AlignTop) | int(Qt.AlignLeft) + | int(Qt.TextWordWrap), + tweet, + ).height() + 40 + + if self.show_replyto: + reply_name = index.data(role=REPLYTOSCREENNAMEROLE) + reply_text = index.data(role=REPLYTEXTROLE) + if reply_name and reply_text: + + # One time is enought sizeHint need to be fast + + reply = 'In reply to @' + reply_name + ' : ' \ + + reply_text + if not self.minifm: + if not self.miniFont: + self.miniFont = QFont(option.font) + self.miniFont.setPointSizeF(option.font.pointSizeF() + * 0.60) + self.minifm = QFontMetrics(self.miniFont) + height += self.minifm.boundingRect( + 0, + 0, + option.rect.width() - 75, + 800, + int(Qt.AlignTop) | int(Qt.AlignLeft) + | int(Qt.TextWordWrap), + reply, + ).height() + elif reply_name: + reply = 'In reply to @' + reply_name + if not self.minifm: + if not self.miniFont: + self.miniFont = QFont(option.font) + self.miniFont.setPointSizeF(option.font.pointSizeF() + * 0.60) + self.minifm = QFontMetrics(self.miniFont) + height += self.minifm.boundingRect( + 0, + 0, + option.rect.width() - 75, + 800, + int(Qt.AlignTop) | int(Qt.AlignLeft) + | int(Qt.TextWordWrap), + reply, + ).height() + + if height < 70: + height = 70 + + self.memoized_size[uid] = QSize(size.width(), height) + return self.memoized_size[uid] + + def paint( + self, + painter, + option, + index, + ): + '''Paint our tweet''' + +# if not USE_PYSIDE: + (x1, y1, x2, y2) = option.rect.getCoords() +# else: + #Work arround Pyside bug #544 +# y1 = option.rect.y() +# y2 = y1 + option.rect.height() +# x1 = option.rect.x() +# x2 = x1 + option.rect.width() +# + # Ugly hack ? + if y1 < 0 and y2 < 0: + return + + if not self.fm: + self.font = QFont(option.font) + self.font.setPointSizeF(option.font.pointSizeF() + * 0.80) + self.fm = QFontMetrics(self.font) + + model = index.model() + tweet = index.data(Qt.DisplayRole) + is_me = index.data(ISMEROLE) + + # Instantiate font only one time ! + + if not self.normFont: + self.normFont = QFont(self.font) + self.miniFont = QFont(option.font) + self.miniFont.setPointSizeF(option.font.pointSizeF() * 0.60) + + painter.save() + + # Draw alternate ? + + if index.row() % 2 == 0: + painter.fillRect(option.rect, self.bg_color) + else: + painter.fillRect(option.rect, self.bg_alternate_color) + + # highlight selected items + + if option.state & QStyle.State_Selected: + painter.fillRect(option.rect, option.palette.highlight()) + + # Draw icon + + if self.show_avatar: + icon = index.data(Qt.DecorationRole) + if icon != None: + if is_me: + painter.drawPixmap(x2 -60, y1 + 10, 50, 50, icon) + else: + painter.drawPixmap(x1 + 10, y1 + 10, 50, 50, icon) + + # Draw tweet + painter.setFont(self.normFont) + painter.setPen(self.text_color) + if is_me: + new_rect = \ + painter.drawText(option.rect.adjusted(4, 5, -70, 0), int(Qt.AlignTop) + | int(Qt.AlignRight) + | int(Qt.TextWordWrap), tweet) + else: + new_rect = \ + painter.drawText(option.rect.adjusted(int(self.show_avatar) + * 70, 5, -4, 0), int(Qt.AlignTop) + | int(Qt.AlignLeft) + | int(Qt.TextWordWrap), tweet) + + # Draw Timeline + + if self.show_timestamp: + time = index.data(role=TIMESTAMPROLE) + painter.setFont(self.miniFont) + painter.setPen(self.time_color) + if is_me: + painter.drawText(option.rect.adjusted(4, 10, -80, -9), + int(Qt.AlignBottom) | int(Qt.AlignRight), + time) + else: + painter.drawText(option.rect.adjusted(70, 10, -10, -9), + int(Qt.AlignBottom) | int(Qt.AlignRight), + time) + + # Draw screenname + + if self.show_screenname: + screenname = index.data(SCREENNAMEROLE) + retweet_of = index.data(RETWEETOFROLE) + if retweet_of: + screenname = '%s : Retweet of %s' % (screenname, retweet_of.user.screen_name) + painter.setFont(self.miniFont) + painter.setPen(self.user_color) + if is_me: + painter.drawText(option.rect.adjusted(4, 10, -70, -9), + int(Qt.AlignBottom) | int(Qt.AlignLeft), + screenname) + else: + painter.drawText(option.rect.adjusted(70, 10, -10, -9), + int(Qt.AlignBottom) | int(Qt.AlignLeft), + screenname) + + # Draw reply + + if self.show_replyto: + reply_name = index.data(role=REPLYTOSCREENNAMEROLE) + reply_text = index.data(role=REPLYTEXTROLE) + if reply_name and reply_text: + reply = 'In reply to ' + reply_name + ' : ' \ + + reply_text + painter.setFont(self.miniFont) + painter.setPen(self.replyto_color) + if is_me: + new_rect = \ + painter.drawText(option.rect.adjusted(4, new_rect.height() + 5, -70, 0), + int(Qt.AlignTop) | int(Qt.AlignLeft) + | int(Qt.TextWordWrap), reply) + else: + new_rect = \ + painter.drawText(option.rect.adjusted(int(self.show_avatar) + * 70, new_rect.height() + 5, -4, 0), + int(Qt.AlignTop) | int(Qt.AlignLeft) + | int(Qt.TextWordWrap), reply) + elif reply_name: + reply = 'In reply to ' + reply_name + painter.setFont(self.miniFont) + painter.setPen(self.replyto_color) + if is_me: + new_rect = \ + painter.drawText(option.rect.adjusted(4, new_rect.height() + 5, -70, 0), + int(Qt.AlignTop) | int(Qt.AlignLeft) + | int(Qt.TextWordWrap), reply) + else: + new_rect = \ + painter.drawText(option.rect.adjusted(int(self.show_avatar) + * 70, new_rect.height() + 5, -4, 0), + int(Qt.AlignTop) | int(Qt.AlignLeft) + | int(Qt.TextWordWrap), reply) + + # Draw line + + painter.setPen(self.separator_color) + painter.drawLine(x1, y2, x2, y2) + + painter.restore() + + +class CoolWhiteCustomDelegate(DefaultCustomDelegate): + + '''Delegate to do custom draw of the items''' + + def __init__(self, parent): + '''Initialization''' + + DefaultCustomDelegate.__init__(self, parent) + + self.user_color = QColor('#3399cc') + self.replyto_color = QColor('#3399cc') + self.time_color = QColor('#94a1a7') + self.bg_color = QColor('#edf1f2') + self.bg_alternate_color = QColor('#e6eaeb') + self.text_color = QColor('#444444') + self.separator_color = QColor('#c8cdcf') + + +class CoolGrayCustomDelegate(DefaultCustomDelegate): + + '''Delegate to do custom draw of the items''' + + def __init__(self, parent): + '''Initialization''' + + DefaultCustomDelegate.__init__(self, parent) + + self.user_color = QColor('#3399cc') + self.time_color = QColor('#94a1a7') + self.replyto_color = QColor('#94a1a7') + self.bg_color = QColor('#4a5153') + self.bg_alternate_color = QColor('#444b4d') + self.text_color = QColor('#FFFFFF') + self.separator_color = QColor('#333536') + + +class KhweetsView(QListView): + + ''' Model View ''' + + def __init__(self, parent=None): + QListView.__init__(self, parent) + self.setWordWrap(True) + self.refreshCustomDelegate() + self.setEditTriggers(QAbstractItemView.SelectedClicked) + self.setSpacing(0) + self.setUniformItemSizes(False) + self.setResizeMode(QListView.Adjust) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) + + def keyPressEvent(self, event): + if event.key() not in (Qt.Key_Up, Qt.Key_Down): + self.parent().switch_tb_edit() + self.parent().tb_text.setFocus() + self.parent().tb_text.keyPressEvent(event) + else: + QListView.keyPressEvent(self, event) + + def refreshCustomDelegate(self): + settings = QSettings("Khertan Software", "Khweeteur") + theme = settings.value('theme') + if theme == KhweeteurPref.WHITETHEME: + self.custom_delegate = WhiteCustomDelegate(self) + elif theme == KhweeteurPref.DEFAULTTHEME: + self.custom_delegate = DefaultCustomDelegate(self) + elif theme == KhweeteurPref.COOLWHITETHEME: + self.custom_delegate = CoolWhiteCustomDelegate(self) + elif theme == KhweeteurPref.COOLGRAYTHEME: + self.custom_delegate = CoolGrayCustomDelegate(self) + elif theme == KhweeteurPref.MINITHEME: + self.custom_delegate = MiniDefaultCustomDelegate(self) + else: + self.custom_delegate = DefaultCustomDelegate(self) + self.setItemDelegate(self.custom_delegate) diff --git a/khweeteur/notifications.py b/khweeteur/notifications.py new file mode 100644 index 0000000..eb980d9 --- /dev/null +++ b/khweeteur/notifications.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python2.5 +# -*- coding: utf-8 -*- +# +# Copyright (c) 2010 Benoît HERVIER +# Licenced under GPLv3 + +from PySide.QtCore import QObject + +try: + import dbus + import dbus.service + from dbus.mainloop.qt import DBusQtMainLoop +# from dbusobj import KhweeteurDBus + noDBUS = False +except: + noDBUS = True + print 'No dbus try with pynotify' + import pynotify + +class KhweeteurNotification(QObject): + '''Notification class interface''' + def __init__(self): + global noDBUS + QObject.__init__(self) + if not noDBUS: + try: + self.m_bus = dbus.SystemBus() + self.m_notify = self.m_bus.get_object('org.freedesktop.Notifications', + '/org/freedesktop/Notifications') + self.iface = dbus.Interface(self.m_notify, 'org.freedesktop.Notifications') + self.m_id = 0 + except: + noDBUS = True + + def warn(self, message): + '''Display an Hildon banner''' + if not noDBUS: + try: + self.iface.SystemNoteDialog(message,0, 'Nothing') + except: + pass + else: + if pynotify.init("Khweeteur"): + n = pynotify.Notification(message, message) + n.show() + + def info(self, message): + '''Display an information banner''' + if not noDBUS: + if isMAEMO: + try: + self.iface.SystemNoteInfoprint('Khweeteur : '+message) + except: + pass + else: + if pynotify.init("Khweeteur"): + n = pynotify.Notification(message, message) + n.show() + + def notify(self,title, message,category='khweeteur-new-tweets', icon='khweeteur',count=1): + '''Create a notification in the style of email one''' + if not noDBUS: + try: + self.m_id = self.iface.Notify('Khweeteur', + self.m_id, + icon, + title, + message, + ['default','call'], + {'category':category, + 'desktop-entry':'khweeteur', + 'dbus-callback-default':'net.khertan.khweeteur /net/khertan/khweeteur net.khertan.khweeteur show_now', + 'count':count, + 'amount':count}, + -1 + ) + except: + pass + + else: + if pynotify.init("Khweeteur"): + n = pynotify.Notification(title, message) + n.show() + diff --git a/khweeteur-experimental/old/__init__.py b/khweeteur/old/__init__.py similarity index 100% rename from khweeteur-experimental/old/__init__.py rename to khweeteur/old/__init__.py diff --git a/khweeteur-experimental/old/client.py b/khweeteur/old/client.py similarity index 100% rename from khweeteur-experimental/old/client.py rename to khweeteur/old/client.py diff --git a/khweeteur-experimental/old/client2.py b/khweeteur/old/client2.py similarity index 100% rename from khweeteur-experimental/old/client2.py rename to khweeteur/old/client2.py diff --git a/khweeteur-experimental/old/daemon.py b/khweeteur/old/daemon.py similarity index 100% rename from khweeteur-experimental/old/daemon.py rename to khweeteur/old/daemon.py diff --git a/khweeteur-experimental/old/oauth2/__init__.py b/khweeteur/old/oauth2/__init__.py similarity index 100% rename from khweeteur-experimental/old/oauth2/__init__.py rename to khweeteur/old/oauth2/__init__.py diff --git a/khweeteur-experimental/old/oauth2/__init__.pyc b/khweeteur/old/oauth2/__init__.pyc similarity index 100% rename from khweeteur-experimental/old/oauth2/__init__.pyc rename to khweeteur/old/oauth2/__init__.pyc diff --git a/khweeteur-experimental/old/oauth2/__init__.pyo b/khweeteur/old/oauth2/__init__.pyo similarity index 100% rename from khweeteur-experimental/old/oauth2/__init__.pyo rename to khweeteur/old/oauth2/__init__.pyo diff --git a/khweeteur-experimental/old/oauth2/clients/__init__.py b/khweeteur/old/oauth2/clients/__init__.py similarity index 100% rename from khweeteur-experimental/old/oauth2/clients/__init__.py rename to khweeteur/old/oauth2/clients/__init__.py diff --git a/khweeteur-experimental/old/oauth2/clients/__init__.pyc b/khweeteur/old/oauth2/clients/__init__.pyc similarity index 100% rename from khweeteur-experimental/old/oauth2/clients/__init__.pyc rename to khweeteur/old/oauth2/clients/__init__.pyc diff --git a/khweeteur-experimental/old/oauth2/clients/imap.py b/khweeteur/old/oauth2/clients/imap.py similarity index 100% rename from khweeteur-experimental/old/oauth2/clients/imap.py rename to khweeteur/old/oauth2/clients/imap.py diff --git a/khweeteur-experimental/old/oauth2/clients/imap.pyc b/khweeteur/old/oauth2/clients/imap.pyc similarity index 100% rename from khweeteur-experimental/old/oauth2/clients/imap.pyc rename to khweeteur/old/oauth2/clients/imap.pyc diff --git a/khweeteur-experimental/old/oauth2/clients/smtp.py b/khweeteur/old/oauth2/clients/smtp.py similarity index 100% rename from khweeteur-experimental/old/oauth2/clients/smtp.py rename to khweeteur/old/oauth2/clients/smtp.py diff --git a/khweeteur-experimental/old/oauth2/clients/smtp.pyc b/khweeteur/old/oauth2/clients/smtp.pyc similarity index 100% rename from khweeteur-experimental/old/oauth2/clients/smtp.pyc rename to khweeteur/old/oauth2/clients/smtp.pyc diff --git a/khweeteur-experimental/old/objects.py b/khweeteur/old/objects.py similarity index 100% rename from khweeteur-experimental/old/objects.py rename to khweeteur/old/objects.py diff --git a/khweeteur-experimental/old/tweetslist.py b/khweeteur/old/tweetslist.py similarity index 100% rename from khweeteur-experimental/old/tweetslist.py rename to khweeteur/old/tweetslist.py diff --git a/khweeteur-experimental/old/tweetslist.pyo b/khweeteur/old/tweetslist.pyo similarity index 100% rename from khweeteur-experimental/old/tweetslist.pyo rename to khweeteur/old/tweetslist.pyo diff --git a/khweeteur-experimental/old/tweetslist.qml b/khweeteur/old/tweetslist.qml similarity index 100% rename from khweeteur-experimental/old/tweetslist.qml rename to khweeteur/old/tweetslist.qml diff --git a/khweeteur-experimental/old/twitter.py b/khweeteur/old/twitter.py similarity index 100% rename from khweeteur-experimental/old/twitter.py rename to khweeteur/old/twitter.py diff --git a/khweeteur-experimental/old/twitter.pyo b/khweeteur/old/twitter.pyo similarity index 100% rename from khweeteur-experimental/old/twitter.pyo rename to khweeteur/old/twitter.pyo diff --git a/khweeteur/qbadgebutton.py b/khweeteur/qbadgebutton.py new file mode 100644 index 0000000..59b3ff6 --- /dev/null +++ b/khweeteur/qbadgebutton.py @@ -0,0 +1,145 @@ +import sys +from PySide.QtGui import * +from PySide.QtCore import Qt + +class QBadgeButton (QPushButton): + + def __init__ (self, icon = None, text = None, parent = None): + if icon: + QPushButton.__init__(self, icon, text, parent) + elif text: + QPushButton.__init__(self, text, parent) + else: + QPushButton.__init__(self, parent) + + self.badge_counter = 0 + self.badge_size = 50 + + self.redGradient = QRadialGradient(0.0, 0.0, 17.0, self.badge_size - 3, self.badge_size - 3); + self.redGradient.setColorAt(0.0, QColor(0xe0, 0x84, 0x9b)); + self.redGradient.setColorAt(0.5, QColor(0xe9, 0x34, 0x43)); + self.redGradient.setColorAt(1.0, QColor(0xdc, 0x0c, 0x00)); + + def setSize (self, size): + self.badge_size = size + + def setCounter (self, counter): + self.badge_counter = counter + self.update() + + def getCounter (self): + return self.badge_counter + + def paintEvent (self, event): + QPushButton.paintEvent(self, event) + p = QPainter(self) + p.setRenderHint(QPainter.TextAntialiasing) + p.setRenderHint(QPainter.Antialiasing) + + if self.badge_counter > 0: + point = self.rect().topRight() + self.drawBadge(p, point.x()-self.badge_size - 1, point.y() + 1, self.badge_size, str(self.badge_counter), QBrush(self.redGradient)) + + def fillEllipse (self, painter, x, y, size, brush): + path = QPainterPath() + path.addEllipse(x, y, size, size); + painter.fillPath(path, brush); + + def drawBadge(self, painter, x, y, size, text, brush): + painter.setFont(QFont(painter.font().family(), 11, QFont.Bold)) + + while ((size - painter.fontMetrics().width(text)) < 10): + pointSize = painter.font().pointSize() - 1 + weight = QFont.Normal if (pointSize < 8) else QFont.Bold + painter.setFont(QFont(painter.font().family(), painter.font().pointSize() - 1, weight)) + + shadowColor = QColor(0, 0, 0, size) + self.fillEllipse(painter, x + 1, y, size, shadowColor) + self.fillEllipse(painter, x - 1, y, size, shadowColor) + self.fillEllipse(painter, x, y + 1, size, shadowColor) + self.fillEllipse(painter, x, y - 1, size, shadowColor) + + painter.setPen(QPen(Qt.white, 2)); + self.fillEllipse(painter, x, y, size - 3, brush) + painter.drawEllipse(x, y, size - 3, size - 3) + + painter.setPen(QPen(Qt.white, 1)); + painter.drawText(x, y, size - 2, size - 2, Qt.AlignCenter, text); + +class QToolBadgeButton (QToolButton): + + def __init__ (self, parent = None): + QToolButton.__init__(self, parent) + + self.badge_counter = 0 + self.badge_size = 25 + + self.redGradient = QRadialGradient(0.0, 0.0, 17.0, self.badge_size - 3, self.badge_size - 3); + self.redGradient.setColorAt(0.0, QColor(0xe0, 0x84, 0x9b)); + self.redGradient.setColorAt(0.5, QColor(0xe9, 0x34, 0x43)); + self.redGradient.setColorAt(1.0, QColor(0xdc, 0x0c, 0x00)); + + def setSize (self, size): + self.badge_size = size + + def setCounter (self, counter): + self.badge_counter = counter + + def getCounter (self): + return self.badge_counter + + def paintEvent (self, event): + QToolButton.paintEvent(self, event) + p = QPainter(self) + p.setRenderHint(QPainter.TextAntialiasing) + p.setRenderHint(QPainter.Antialiasing) + if self.badge_counter > 0: + point = self.rect().topRight() + self.drawBadge(p, point.x()-self.badge_size, point.y(), self.badge_size, str(self.badge_counter), QBrush(self.redGradient)) + + def fillEllipse (self, painter, x, y, size, brush): + path = QPainterPath() + path.addEllipse(x, y, size, size); + painter.fillPath(path, brush); + + def drawBadge(self, painter, x, y, size, text, brush): + painter.setFont(QFont(painter.font().family(), 11, QFont.Bold)) + + while ((size - painter.fontMetrics().width(text)) < 10): + pointSize = painter.font().pointSize() - 1 + weight = QFont.Normal if (pointSize < 8) else QFont.Bold + painter.setFont(QFont(painter.font().family(), painter.font().pointSize() - 1, weight)) + + shadowColor = QColor(0, 0, 0, size) + self.fillEllipse(painter, x + 1, y, size, shadowColor) + self.fillEllipse(painter, x - 1, y, size, shadowColor) + self.fillEllipse(painter, x, y + 1, size, shadowColor) + self.fillEllipse(painter, x, y - 1, size, shadowColor) + + painter.setPen(QPen(Qt.white, 2)); + self.fillEllipse(painter, x, y, size - 3, brush) + painter.drawEllipse(x, y, size - 2, size - 2) + + painter.setPen(QPen(Qt.white, 1)); + painter.drawText(x, y, size - 2, size - 2, Qt.AlignCenter, text); + +if __name__ == '__main__': + + app = QApplication(sys.argv) + win = QMainWindow() + + toolbar = QToolBar('Toolbar') + win.addToolBar(Qt.BottomToolBarArea, toolbar) + b = QToolBadgeButton(win) + b.setText("test") + b.setCounter(22) + toolbar.addWidget(b) + + w = QBadgeButton(parent=win) + w.setText("test") + w.setCounter(22) + win.setCentralWidget(w) + win.show() + + sys.exit(app.exec_()) + diff --git a/khweeteur-experimental/qml/add.png b/khweeteur/qml/add.png similarity index 100% rename from khweeteur-experimental/qml/add.png rename to khweeteur/qml/add.png diff --git a/khweeteur-experimental/qml/default.png b/khweeteur/qml/default.png similarity index 100% rename from khweeteur-experimental/qml/default.png rename to khweeteur/qml/default.png diff --git a/khweeteur-experimental/qml/fullsize.png b/khweeteur/qml/fullsize.png similarity index 100% rename from khweeteur-experimental/qml/fullsize.png rename to khweeteur/qml/fullsize.png diff --git a/khweeteur-experimental/qml/house.png b/khweeteur/qml/house.png similarity index 100% rename from khweeteur-experimental/qml/house.png rename to khweeteur/qml/house.png diff --git a/khweeteur/qml/khweeteur.png b/khweeteur/qml/khweeteur.png new file mode 100644 index 0000000000000000000000000000000000000000..c6f76919ccedf078f16106efa5d39aa89d6ac090 GIT binary patch literal 2876 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 aVc>r#H2kmPEaEx<0000Khweeteur %s +

An easy to use twitter client +

Licenced under GPLv3 +
By Benoît HERVIER (Khertan) +

Khweeteur try to be simple and fast +
identi.ca and twitter client
+

Features +
Support multiple account +
Notify DMs and Mentions even when not launched +
Reply, Retweet, Follow/Unfollow user, Favorite, Delete your tweet +
Disconnected mode, action will be done when you recover network +
Twitpic upload +
Automated OAuth authentification +

Shortcuts : +
Control-R : Refresh current view +
Control-M : Reply to selected tweet +
Control-Up : To scroll to top +
Control-Bottom : To scroll to bottom +

Thanks to : +
ddoodie on #pyqt +
xnt14 on #maemo +
trebormints on twitter +
moubaildotcom on twitter +
teotwaki on twitter +
Jaffa on maemo.org +
creip on Twitter +
zcopley on #statusnet +
jordan_c on #statusnet + ''') + % __version__) + aboutLayout.addWidget(aboutLabel) + self.bugtracker_button = QPushButton(self.tr('BugTracker')) + self.bugtracker_button.clicked.connect(self.open_bugtracker) + self.website_button = QPushButton(self.tr('Website')) + self.website_button.clicked.connect(self.open_website) + awidget2 = QWidget() + buttonLayout = QHBoxLayout(awidget2) + buttonLayout.addWidget(self.bugtracker_button) + buttonLayout.addWidget(self.website_button) + aboutLayout.addWidget(awidget2) + + try: + awidget.setLayout(aboutLayout) + aboutScrollArea.setWidget(awidget) + self.setCentralWidget(aboutScrollArea) + except: + self.setCentralWidget(awidget) + + self.show() + + def open_website(self): + QDesktopServices.openUrl(QUrl('http://khertan.net/khweeteur')) + + def open_bugtracker(self): + QDesktopServices.openUrl(QUrl('http://khertan.net/khweeteur/bugs' + )) +class Khweeteur(QApplication): + def __init__(self): + QApplication.__init__(self,sys.argv) + self.setOrganizationName("Khertan Software") + self.setOrganizationDomain("khertan.net") + self.setApplicationName("Khweeteur") + + self.run() + + def run(self): + self.win = KhweeteurWin() + self.win.show() + +class KhweeteurWin(QMainWindow): + def __init__(self,parent=None): + QMainWindow.__init__(self,parent) + + self.setAttribute(Qt.WA_Maemo5AutoOrientation, True) + self.setAttribute(Qt.WA_Maemo5StackedWindow, True) + self.setWindowTitle('Khweeteur') + + self.listen_dbus() + + self.view = KhweetsView() + self.model = KhweetsModel() + self.view.setModel(self.model) + self.view.clicked.connect(self.switch_tb_action) + + self.dbus_handler.require_update() + + self.toolbar = QToolBar('Toolbar') + self.addToolBar(Qt.BottomToolBarArea, self.toolbar) + + self.toolbar_mode = 0 #0 - Default , 1 - Edit, 2 - Action + + self.list_tb_action = [] + self.edit_tb_action = [] + self.action_tb_action = [] + + #Switch to edit mode (default) + self.tb_new = QAction(QIcon.fromTheme('khweeteur' + ), 'New', self) + self.tb_new.triggered.connect(self.switch_tb_edit) + self.toolbar.addAction(self.tb_new) + self.list_tb_action.append(self.tb_new) + + #Back button (Edit + Action) + self.tb_back = QAction(QIcon.fromTheme('general_back' + ), 'Back', self) + self.tb_back.triggered.connect(self.switch_tb_default) + self.toolbar.addAction(self.tb_back) + self.edit_tb_action.append(self.tb_back) + self.action_tb_action.append(self.tb_back) + + self.setupMenu() + + #Twitpic button + self.tb_twitpic = QAction(QIcon.fromTheme('tasklaunch_images' + ), 'Twitpic', self) + self.tb_twitpic.triggered.connect(self.do_tb_twitpic) + self.toolbar.addAction(self.tb_twitpic) + self.edit_tb_action.append(self.tb_twitpic) + + #Text field (edit) + self.tb_text = QPlainTextEdit() + self.tb_text_reply_id = 0 + self.tb_text_reply_base_url = '' + self.tb_text.setFixedHeight(66) + self.edit_tb_action.append(self.toolbar.addWidget(self.tb_text)) + + #Char count (Edit) + self.tb_charCounter = QLabel('140') + self.edit_tb_action.append(self.toolbar.addWidget(self.tb_charCounter)) + self.tb_text.textChanged.connect(self.countCharsAndResize) + + #Send tweet (Edit) + self.tb_send = QAction(QIcon.fromTheme('khweeteur' + ), 'Tweet', self) + self.tb_send.triggered.connect(self.do_tb_send) + self.tb_send.setVisible(False) + self.toolbar.addAction(self.tb_send) + self.edit_tb_action.append(self.tb_send) + + #Refresh (Default) + self.tb_update = QAction(QIcon.fromTheme('general_refresh' + ), 'Update', self) + self.tb_update.triggered.connect(self.dbus_handler.require_update) + self.toolbar.addAction(self.tb_update) + self.list_tb_action.append(self.tb_update) + + #Home (Default) + self.home_button = QToolBadgeButton(self) + self.home_button.setText("Home") + self.home_button.setCheckable(True) + self.home_button.setChecked(True) + self.home_button.clicked.connect(self.show_hometimeline) + self.list_tb_action.append(self.toolbar.addWidget(self.home_button)) + + #Mentions (Default) + self.mention_button = QToolBadgeButton(self) + self.mention_button.setText("Mentions") + self.mention_button.setCheckable(True) + self.mention_button.clicked.connect(self.show_mentions) + self.list_tb_action.append(self.toolbar.addWidget(self.mention_button)) + + #DM (Default) + self.msg_button = QToolBadgeButton(self) + self.msg_button.setText("DMs") + self.msg_button.setCheckable(True) + self.msg_button.clicked.connect(self.show_dms) + self.list_tb_action.append(self.toolbar.addWidget(self.msg_button)) + + #Search Button + self.tb_search_menu = QMenu() + self.loadSearchMenu() + + #Search (Default) + self.tb_search_button = QToolBadgeButton(self) + self.tb_search_button.setText("") + self.tb_search_button.setIcon(QIcon.fromTheme('general_search')) + self.tb_search_button.setMenu(self.tb_search_menu) + self.tb_search_button.setPopupMode(QToolButton.InstantPopup) + self.tb_search_button.setCheckable(True) + self.tb_search_button.clicked.connect(self.show_search) + self.list_tb_action.append(self.toolbar.addWidget(self.tb_search_button)) + + #Reply button (Action) + self.tb_reply = QAction('Reply', self) + self.tb_reply.setShortcut('Ctrl+M') + self.toolbar.addAction(self.tb_reply) + self.tb_reply.triggered.connect(self.do_tb_reply) + self.action_tb_action.append(self.tb_reply) + + #Retweet (Action) + self.tb_retweet = QAction('Retweet', self) + self.tb_retweet.setShortcut('Ctrl+P') + self.toolbar.addAction(self.tb_retweet) + self.tb_retweet.triggered.connect(self.do_tb_retweet) + self.action_tb_action.append(self.tb_retweet) + + #Follow (Action) + self.tb_follow = QAction('Follow', self) + self.tb_follow.triggered.connect(self.do_tb_follow) + self.toolbar.addAction(self.tb_follow) + self.action_tb_action.append(self.tb_follow) + + #UnFollow (Action) + self.tb_unfollow = QAction('Unfollow', self) + self.tb_unfollow.triggered.connect(self.do_tb_unfollow) + self.toolbar.addAction(self.tb_unfollow) + self.action_tb_action.append(self.tb_unfollow) + + #Favorite (Action) + self.tb_favorite = QAction('Favorite', self) + self.tb_favorite.triggered.connect(self.do_tb_favorite) + self.toolbar.addAction(self.tb_favorite) + self.action_tb_action.append(self.tb_favorite) + + #Open URLs (Action) + self.tb_urls = QAction('Open URLs', self) + self.tb_urls.setShortcut('Ctrl+O') + self.toolbar.addAction(self.tb_urls) + self.tb_urls.triggered.connect(self.do_tb_openurl) + self.action_tb_action.append(self.tb_urls) + + #Delete (Action) + self.tb_delete = QAction('Delete', self) + self.toolbar.addAction(self.tb_delete) + self.tb_delete.triggered.connect(self.do_tb_delete) + self.action_tb_action.append(self.tb_delete) + + # Actions not in toolbar + + self.tb_scrolltop = QAction('Scroll to top', self) + self.tb_scrolltop.setShortcut(Qt.CTRL + Qt.Key_Up) + self.tb_scrolltop.triggered.connect(self.view.scrollToTop) + self.addAction(self.tb_scrolltop) + + self.tb_scrollbottom = QAction('Scroll to bottom', self) + self.tb_scrollbottom.setShortcut(Qt.CTRL + Qt.Key_Down) + self.tb_scrollbottom.triggered.connect(self.view.scrollToBottom) + self.addAction(self.tb_scrollbottom) + + self.switch_tb_default() + + self.model.load('HomeTimeline') + self.setCentralWidget(self.view) + + QApplication.processEvents() + + self.geolocDoStart() + + def enterEvent(self,event): + """ + Redefine the enter event to refresh recent file list + """ + print 'EnterEvent' + self.model.refreshTimestamp() + + def listen_dbus(self): + from dbus.mainloop.qt import DBusQtMainLoop + self.dbus_loop = DBusQtMainLoop() + dbus.set_default_main_loop(self.dbus_loop) + self.bus = dbus.SessionBus() + #Connect the new tweet signal + self.bus.add_signal_receiver(self.new_tweets, path='/net/khertan/Khweeteur', dbus_interface='net.khertan.Khweeteur', signal_name='new_tweets') + self.bus.add_signal_receiver(self.stop_spinning, path='/net/khertan/Khweeteur', dbus_interface='net.khertan.Khweeteur', signal_name='refresh_ended') + self.dbus_handler = KhweeteurDBusHandler(self) + + def stop_spinning(self): + print 'DEBUG : stop_spinning' + self.setAttribute(Qt.WA_Maemo5ShowProgressIndicator , False) + + def new_tweets(self,count,msg): + print 'New Tweets dbus signal received' + print count,msg + if msg == 'HomeTimeline': + self.home_button.setCounter(self.home_button.getCounter()+count) + QApplication.processEvents() + elif msg == 'Mentions': + self.mention_button.setCounter(self.mention_button.getCounter()+count) + QApplication.processEvents() + elif msg == 'DMs': + self.msg_button.setCounter(self.msg_button.getCounter()+count) + QApplication.processEvents() + elif msg.startswith('Search:'): + self.tb_search_button.setCounter(self.tb_search_button.getCounter()+count) + QApplication.processEvents() + + if self.model.call == msg: + print 'DEBUG : new_tweets model.load' + self.model.load(msg) + print 'DEBUG : new_tweet end model.load' + + print 'DEBUG : end new_tweet' + + @pyqtSlot() + def show_search(self): + terms = self.sender().text() + self.tb_search_button.setCounter(0) + self.home_button.setChecked(False) + self.msg_button.setChecked(False) + self.tb_search_button.setChecked(True) + self.mention_button.setChecked(False) + self.view.scrollToTop() + self.model.load('Search:'+terms) + + @pyqtSlot() + def show_hometimeline(self): + self.home_button.setCounter(0) + self.home_button.setChecked(True) + self.msg_button.setChecked(False) + self.tb_search_button.setChecked(False) + self.mention_button.setChecked(False) + self.view.scrollToTop() + self.model.load('HomeTimeline') + + @pyqtSlot() + def switch_tb_default(self): + print 'Switch tb default' + self.tb_text.setPlainText('') + self.tb_text_reply_id = 0 + self.tb_text_reply_base_url = '' + self.toolbar_mode = 0 + self.switch_tb() + + @pyqtSlot() + def switch_tb_edit(self): + print 'Switch tb edit' + self.toolbar_mode = 1 + self.switch_tb() + + @pyqtSlot() + def switch_tb_action(self): + if self.toolbar_mode != 2: + self.toolbar_mode = 2 + self.switch_tb() + for index in self.view.selectedIndexes(): + isme = self.model.data(index, role=ISMEROLE) + if isme: + self.tb_follow.setVisible(False) + self.tb_unfollow.setVisible(False) + self.tb_delete.setVisible(True) + else: + self.tb_delete.setVisible(False) + self.tb_follow.setVisible(True) + self.tb_unfollow.setVisible(True) + + def switch_tb(self): + mode = self.toolbar_mode + print mode,type(mode) + for item in self.list_tb_action: + item.setVisible(mode == 0) + self.view.setFocus() + for item in self.edit_tb_action: + item.setVisible(mode == 1) + if mode == 1: + self.tb_text.setFocus() + for item in self.action_tb_action: + item.setVisible(mode == 2) + if mode in (1, 2): + self.tb_back.setVisible(True) + + @pyqtSlot() + def do_tb_twitpic(self): + pass + + @pyqtSlot() + def do_tb_openurl(self): + for index in self.view.selectedIndexes(): + status = self.model.data(index) + try: + urls = re.findall("(?Phttps?://[^\s]+)", status) + for url in urls: + QDesktopServices.openUrl(QUrl(url)) + except: + raise + + @pyqtSlot() + def do_tb_send(self): + is_not_reply = self.tb_text_reply_id==0 + self.dbus_handler.post_tweet( \ + 1,#shorten_url=\ + 1,#serialize=\ + self.tb_text.toPlainText(),#text=\ + '' if self.geoloc_source==None else self.geoloc_source[0], #lattitude = + '' if self.geoloc_source==None else self.geoloc_source[1], #longitude = + '' if is_not_reply else self.tb_text_reply_base_url, #base_url + 'tweet' if is_not_reply else 'reply', #action + '' if is_not_reply else str(self.tb_text_reply_id),) + self.switch_tb_default() + self.dbus_handler.require_update() + + @pyqtSlot() + def do_tb_reply(self): + tweet_id = None + for index in self.view.selectedIndexes(): + tweet_id = self.model.data(index, role=IDROLE) + tweet_source = self.model.data(index, role=ORIGINROLE) + tweet_screenname = self.model.data(index, role=SCREENNAMEROLE) + if tweet_id: + self.tb_text.setPlainText('@'+tweet_screenname+self.tb_text.toPlainText()) + self.tb_text_reply_id = tweet_id + self.tb_text_reply_base_url = tweet_source + self.switch_tb_edit() + + @pyqtSlot() + def do_tb_retweet(self): + tweet_id = None + for index in self.view.selectedIndexes(): + tweet_id = self.model.data(index, role=IDROLE) + tweet_source = self.model.data(index, role=ORIGINROLE) + print 'protected ?',self.model.data(index, role=PROTECTEDROLE),type(self.model.data(index, role=PROTECTEDROLE)) + if self.model.data(index, role=PROTECTEDROLE): + screenname = self.model.data(index, role=SCREENNAMEROLE) + QMessageBox.warning(self, + "Khweeteur - Retweet", + "%s protect his tweets you can't retweet them" % screenname, + QMessageBox.Close + ) + + if tweet_id: + self.dbus_handler.post_tweet( \ + 0,#shorten_url=\ + 0,#serialize=\ + '',#text=\ + '', #lattitude = + '', #longitude = + tweet_source, #base_url = + 'retweet', + str(tweet_id), #tweet_id = + ) + self.switch_tb_default() + self.dbus_handler.require_update() + + @pyqtSlot() + def do_tb_delete(self): + tweet_id = None + for index in self.view.selectedIndexes(): + tweet_id = self.model.data(index, role=IDROLE) + tweet_source = self.model.data(index, role=ORIGINROLE) + + if tweet_id: + self.dbus_handler.post_tweet( \ + 0,#shorten_url=\ + 0,#serialize=\ + '',#text=\ + '', #lattitude = + '', #longitude = + tweet_source, #base_url = + 'delete', + str(tweet_id), #tweet_id = + ) + self.switch_tb_default() + self.dbus_handler.require_update() + + @pyqtSlot() + def do_tb_favorite(self): + tweet_id = None + for index in self.view.selectedIndexes(): + tweet_id = self.model.data(index, role=IDROLE) + tweet_source = self.model.data(index, role=ORIGINROLE) + + if tweet_id: + self.dbus_handler.post_tweet( \ + 0,#shorten_url=\ + 0,#serialize=\ + '',#text=\ + '', #lattitude = + '', #longitude = + tweet_source, #base_url = + 'favorite', + str(tweet_id), #tweet_id = + ) + self.switch_tb_default() + self.dbus_handler.require_update() + + @pyqtSlot() + def do_tb_follow(self): + user_id = None + for index in self.view.selectedIndexes(): + user_id = self.model.data(index, role=USERIDROLE) + tweet_source = self.model.data(index, role=ORIGINROLE) + + if user_id: + self.dbus_handler.post_tweet( \ + 0,#shorten_url=\ + 0,#serialize=\ + '',#text=\ + '', #lattitude = + '', #longitude = + tweet_source, #base_url = + 'follow', + str(user_id), #tweet_id = + ) + self.switch_tb_default() + self.dbus_handler.require_update() + + @pyqtSlot() + def do_tb_unfollow(self): + user_id = None + for index in self.view.selectedIndexes(): + user_id = self.model.data(index, role=USERIDROLE) + tweet_source = self.model.data(index, role=ORIGINROLE) + + if user_id: + self.dbus_handler.post_tweet( \ + 0,#shorten_url=\ + 0,#serialize=\ + '',#text=\ + '', #lattitude = + '', #longitude = + tweet_source, #base_url = + 'unfollow', + str(user_id), #tweet_id = + ) + self.switch_tb_default() + self.dbus_handler.require_update() + + @pyqtSlot() + def show_mentions(self): + self.mention_button.setCounter(0) + self.mention_button.setChecked(True) + self.msg_button.setChecked(False) + self.tb_search_button.setChecked(False) + self.home_button.setChecked(False) + self.view.scrollToTop() + self.model.load('Mentions') + + @pyqtSlot() + def show_dms(self): + self.msg_button.setCounter(0) + self.msg_button.setChecked(True) + self.home_button.setChecked(False) + self.tb_search_button.setChecked(False) + self.mention_button.setChecked(False) + self.view.scrollToTop() + self.model.load('DMs') + + @pyqtSlot() + def countCharsAndResize(self): + local_self = self.tb_text + self.tb_charCounter.setText(unicode(140 + - len(local_self.toPlainText()))) + doc = local_self.document() + cursor = local_self.cursorRect() + s = doc.size() + s.setHeight((s.height() + 1) + * (local_self.fontMetrics().lineSpacing() + 1) + - 21) + fr = local_self.frameRect() + cr = local_self.contentsRect() + local_self.setFixedHeight(min(370, s.height() + fr.height() + - cr.height() - 1)) + + def loadSearchMenu(self): + settings = QSettings() + searches = [] + self.tb_search_menu.clear () + self.tb_search_menu.addAction(QIcon.fromTheme('general_add'), 'New', self.newSearchAsk) + + nb_searches = settings.beginReadArray('searches') + for index in range(nb_searches): + settings.setArrayIndex(index) + self.tb_search_menu.addAction(settings.value('terms'), self.show_search) + settings.endArray() + + def newSearchAsk(self): + (search_terms, ok) = QInputDialog.getText(self, + self.tr('Search'), + self.tr('Enter the search keyword(s) :')) + if ok == 1: + #FIXME : Create the search + self.tb_search_menu.addAction(search_terms, self.show_search) + settings = QSettings() + nb_searches = settings.beginWriteArray('searches') + for index,action in enumerate(self.tb_search_menu.actions()): + #pass the first which are the new option + if index==0: + continue + settings.setArrayIndex(index-1) + settings.setValue('terms',action.text()) + settings.endArray() + settings.sync() + self.dbus_handler.require_update() + + def setupMenu(self): + """ + Initialization of the maemo menu + """ + + fileMenu = QMenu(self.tr("&Menu"), self) + self.menuBar().addMenu(fileMenu) + + fileMenu.addAction(self.tr("&Preferences..."), self.showPrefs) + fileMenu.addAction(self.tr("&About"), self.showAbout) + + @pyqtSlot() + def showPrefs(self): + khtsettings = KhweeteurPref(parent=self) + khtsettings.save.connect(self.refreshPrefs) + khtsettings.show() + + @pyqtSlot() + def refreshPrefs(self): + self.view.refreshCustomDelegate() + self.geolocDoStart() + + @pyqtSlot() + def showAbout(self): + if not hasattr(self,'aboutWin'): + self.aboutWin = KhweeteurAbout(self) + self.aboutWin.show() + + settings = QSettings() + + def geolocDoStart(self): + settings = QSettings() + self.geoloc_source = None + if settings.contains('useGPS'): + if settings.value('useGPS') == 'true': + self.geolocStart() + + def geolocStart(self): + '''Start the GPS with a 50000 refresh_rate''' + self.geoloc_coordinates = None + if self.geoloc_source is None: + self.geoloc_source = \ + QGeoPositionInfoSource.createDefaultSource(None) + if self.geoloc_source is not None: + self.geoloc_source.setUpdateInterval(50000) + self.geoloc_source.positionUpdated.connect(self.geolocUpdated) + self.geoloc_source.startUpdates() + + def geolocStop(self): + '''Stop the GPS''' + self.geoloc_coordinates = None + if self.geoloc_source is not None: + self.geoloc_source.stopUpdates() + self.geoloc_source = None + + def geolocUpdated(self, update): + '''GPS Callback on update''' + if update.isValid(): + self.geoloc_coordinates = (update.coordinate().latitude(), + update.coordinate().longitude()) + else: + print 'GPS Update not valid' + +if __name__ == '__main__': + from subprocess import Popen + Popen(['/usr/bin/python',os.path.join(os.path.dirname(__file__),'daemon.py'),'start']) + app = Khweeteur() + app.exec_() diff --git a/khweeteur/retriever.py b/khweeteur/retriever.py new file mode 100644 index 0000000..3d27856 --- /dev/null +++ b/khweeteur/retriever.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# Copyright (c) 2010 Benoît HERVIER +# Licenced under GPLv3 + +import twitter +import socket +socket.setdefaulttimeout(60) +from urllib import urlretrieve + +import urllib2 +import pickle +try: + from PIL import Image +except: + import Image + +from PySide.QtCore import QSettings + +from threading import Thread + +import logging +import os +import dbus +import dbus.service +import socket + + +class KhweeteurRefreshWorker(Thread): + def __init__(self,base_url, consumer_key, consumer_secret, access_token, access_secret, call, dbus_handler): + Thread.__init__(self, None) + self.api = twitter.Api(username=consumer_key, + password=consumer_secret, + access_token_key=access_token, + access_token_secret=access_secret, + base_url=base_url) + self.api.SetUserAgent('Khweeteur') + self.call = call + self.consumer_key = consumer_key + self.dbus_handler = dbus_handler + socket.setdefaulttimeout(60) + + def send_notification(self,msg,count): + self.dbus_handler.new_tweets(count,msg) + + def getCacheFolder(self): + if not hasattr(self,'folder_path'): + self.folder_path = os.path.join(os.path.expanduser("~"), + '.khweeteur','cache', + os.path.normcase(unicode(self.call.replace('/', + '_'))).encode('UTF-8')) + + if not os.path.isdir(self.folder_path): + try: + os.makedirs(self.folder_path) + except IOError, e: + logging.debug('getCacheFolder:' + e) + + return self.folder_path + + def downloadProfilesImage(self, statuses): + avatar_path = os.path.join(os.path.expanduser("~"), + '.khweeteur','avatars') + if not os.path.exists(avatar_path): + os.makedirs(avatar_path) + + for status in statuses: + if type(status) != twitter.DirectMessage: + cache = os.path.join(avatar_path, os.path.basename(status.user.profile_image_url.replace('/', '_'))) + if not os.path.exists(cache): + try: + urlretrieve(status.user.profile_image_url, + cache) + im = Image.open(cache) + im = im.resize((50, 50)) + im.save(os.path.splitext(cache)[0] + '.png', + 'PNG') +# status.user.profile_image_url = cache + except StandardError, err: + logging.debug('DownloadProfilImage:' + str(err)) + print err + + def removeAlreadyInCache(self, statuses): + # Load cached statuses + try: + folder_path = self.getCacheFolder() + for status in statuses: + if os.path.exists(os.path.join(folder_path, + str(status.id))): + statuses.remove(status) + except StandardError, err: + logging.debug(err) + + def applyOrigin(self, statuses): + for status in statuses: + status.base_url = self.api.base_url + + def getOneReplyContent(self, tid): + #Got from cache + status = None + for root,dirs,files in os.walk(os.path.join(os.path.expanduser("~"),'.khweeteur','cache')): + for folder in dirs: + logging.debug('getOneReplyContent Folder: %s' % (folder,)) + for afile in files: + logging.debug('getOneReplyContent aFile: %s' % (os.path.join(root,afile),)) + if unicode(tid) == afile: + try: + fhandle = open(os.path.join(root,afile), 'rb') + status = pickle.load(fhandle) + fhandle.close() + return status.text + except StandardError,err: + logging.debug('getOneReplyContent:' + err) + + try: + rpath = os.path.join(os.path.expanduser("~"),'.khweeteur','cache','Replies') + if not os.path.exists(rpath): + os.makedirs(rpath) + + status = self.api.GetStatus(tid) + fhandle = open(os.path.join(os.path.join(rpath, + unicode(status.id))), 'wb') + pickle.dump(status, fhandle, pickle.HIGHEST_PROTOCOL) + fhandle.close() + return status.text + except StandardError, err: + logging.debug('getOneReplyContent:' + str(err)) + print err + + def isMe(self,statuses): + try: + me = self.api.VerifyCredentials() + except StandardError, err: + logging.debug('IsMe: %s' % (str(err),)) + for status in statuses: + if status.user.id == me.id: + status.is_me = True + else: + status.is_me = False + + def getRepliesContent(self, statuses): + for status in statuses: + try: + if not hasattr(status, 'in_reply_to_status_id'): + status.in_reply_to_status_text = None + elif not status.in_reply_to_status_text \ + and status.in_reply_to_status_id: + status.in_reply_to_status_text = \ + self.getOneReplyContent(status.in_reply_to_status_id) + except StandardError, err: + logging.debug('getOneReplyContent:' + err) + print err + + def serialize(self, statuses): + folder_path = self.getCacheFolder() + + for status in statuses: + try: + fhandle = open(os.path.join(folder_path, unicode(status.id)),'wb') + pickle.dump(status, fhandle, pickle.HIGHEST_PROTOCOL) + fhandle.close() + except: + logging.debug('Serialization of %s failed' % (status.id,)) + + def run(self): + settings = QSettings("Khertan Software", "Khweeteur") + statuses = [] + + logging.debug('Thread Runned') + try: + since = settings.value(self.consumer_key + '_' + self.call) + + if self.call == 'HomeTimeline': + logging.debug('%s running' % self.call) + statuses = self.api.GetHomeTimeline(since_id=since) + logging.debug('%s finished' % self.call) + elif self.call == 'Mentions': + logging.debug('%s running' % self.call) + statuses = self.api.GetMentions(since_id=since) + logging.debug('%s finished' % self.call) + elif self.call == 'DMs': + logging.debug('%s running' % self.call) + statuses = self.api.GetDirectMessages(since_id=since) + logging.debug('%s finished' % self.call) + #Its a search .... or a list + elif self.call.startswith('Search:'): + logging.debug('%s running' % self.call) + statuses = self.api.GetSearch(since_id=since, term=self.call.split(':')[1]) + logging.debug('%s finished' % self.call) + else: + logging.error('Unknow call : %s' % (self.call,)) + + except StandardError, err: + logging.debug(err) + raise err + + self.removeAlreadyInCache(statuses) + if len(statuses) > 0: + logging.debug('%s start download avatars' % self.call) + self.downloadProfilesImage(statuses) + logging.debug('%s start applying origin' % self.call) + self.applyOrigin(statuses) + logging.debug('%s start getreply' % self.call) + self.getRepliesContent(statuses) + if self.call != 'DMs': + logging.debug('%s start isMe' % self.call) + self.isMe(statuses) + logging.debug('%s start serialize' % self.call) + self.serialize(statuses) + statuses.sort() + statuses.reverse() + settings.setValue(self.consumer_key + \ + '_' + self.call, statuses[0].id) + self.send_notification(self.call,len(statuses)) + settings.sync() + logging.debug('%s refreshed' % self.call) diff --git a/khweeteur/settings.py b/khweeteur/settings.py new file mode 100644 index 0000000..e4d373f --- /dev/null +++ b/khweeteur/settings.py @@ -0,0 +1,448 @@ +#!/usr/bin/env python2.5 +# -*- coding: utf-8 -*- +# +# Copyright (c) 2010 Benoît HERVIER +# Licenced under GPLv3 +'''A simple Twitter client made with pyqt4''' + +import datetime +import httplib2 +import re + +#import sip +#sip.setapi('QString', 2) +#sip.setapi('QVariant', 2) + +from PySide.QtGui import QMainWindow, \ + QSizePolicy, \ + QSpinBox, \ + QVBoxLayout, \ + QDesktopServices, \ + QAbstractItemView, \ + QScrollArea, \ + QListView, \ + QComboBox, \ + QCheckBox, \ + QDialog, \ + QGridLayout, \ + QWidget, \ + QToolBar, \ + QLabel, \ + QPushButton, \ + QInputDialog, \ + QKeySequence, \ + QMenu, \ + QAction, \ + QApplication, \ + QIcon, \ + QMessageBox, \ + QPlainTextEdit + +from PySide.QtCore import Qt, \ + QUrl, \ + QAbstractListModel, \ + QSettings, \ + QModelIndex, \ + Signal + +#Signal = pyqtSignal + +SUPPORTED_ACCOUNTS = [{'name':'Twitter', + 'consumer_key':'uhgjkoA2lggG4Rh0ggUeQ', + 'consumer_secret':'lbKAvvBiyTlFsJfb755t3y1LVwB0RaoMoDwLD14VvU', + 'base_url':'https://api.twitter.com/1', + 'request_token_url':'https://api.twitter.com/oauth/request_token', + 'access_token_url':'https://api.twitter.com/oauth/access_token', + 'authorization_url':'https://api.twitter.com/oauth/authorize'}, + {'name':'Identi.ca', + 'consumer_key':'c7e86efd4cb951871200440ad1774413', + 'consumer_secret':'236fa46bf3f65fabdb1fd34d63c26d28', + 'base_url':'http://identi.ca/api', + 'request_token_url':'http://identi.ca/api/oauth/request_token', + 'access_token_url':'http://identi.ca/api/oauth/access_token', + 'authorization_url':'http://identi.ca/api/oauth/authorize'}, + ] + +import oauth2 as oauth +from notifications import KhweeteurNotification + +try: + from urlparse import parse_qs +except: + from cgi import parse_qs + +from PySide.QtWebKit import * + + +class OAuthView(QWebView): + gotpin = Signal(unicode) + + def __init__(self, parent=None, account_type={}, use_for_tweet=False): + QWebView.__init__(self, parent) + self.loggedIn = False + self.account_type = account_type + self.use_for_tweet = use_for_tweet + self.pin = None + + def open(self, url): + """.""" + self.url = QUrl(url) + self.loadFinished.connect(self._loadFinished) + self.load(self.url) + self.show() + + def createWindow(self, windowType): + """Load links in the same web-view.""" + return self + + def _loadFinished(self): + + regex = re.compile('.*(.*)<') + res = regex.findall(self.page().mainFrame().toHtml()) + if len(res) > 0: + self.pin = res[0] + + self.loggedIn = (self.pin not in (None, '')) + + if self.loggedIn: + self.loadFinished.disconnect(self._loadFinished) + self.gotpin.emit(self.pin) + + +class AccountDlg(QDialog): + """ Find and replace dialog """ + add_account = Signal(dict, bool) + + def __init__(self, parent=None): + QDialog.__init__(self, parent) + self.setWindowTitle("Add account") + + self.accounts_type = QComboBox() + for account_type in SUPPORTED_ACCOUNTS: + self.accounts_type.addItem(account_type['name']) + + self.use_for_tweet = QCheckBox("Use for posting") + + self.add = QPushButton("&Add") + + gridLayout = QGridLayout() + gridLayout.addWidget(self.accounts_type, 0, 0) + gridLayout.addWidget(self.use_for_tweet, 0, 1) + gridLayout.addWidget(self.add, 1, 2) + self.setLayout(gridLayout) + self.add.clicked.connect(self.addit) + + def addit(self): + index = self.accounts_type.currentIndex() + self.add_account.emit(SUPPORTED_ACCOUNTS[index], + self.use_for_tweet.isChecked()) + self.hide() + + +class AccountsModel(QAbstractListModel): + dataChanged = Signal(QModelIndex, QModelIndex) + + def __init__(self): + QAbstractListModel.__init__(self) + self._items = [] + + def set(self, mlist): + self._items = mlist + self.dataChanged.emit(self.createIndex(0, 0), + self.createIndex(0, + len(self._items))) + + def rowCount(self, parent=QModelIndex()): + return len(self._items) + + def data(self, index, role=Qt.DisplayRole): + if role == Qt.DisplayRole: + return self._items[index.row()].name + else: + return None + + +class AccountsView(QListView): + + def __init__(self, parent=None): + QListView.__init__(self, parent) + self.setEditTriggers(QAbstractItemView.SelectedClicked) + + +class KhweeteurAccount(): + + def __init__(self, name='Unknow', consumer_key='', consumer_secret='', token_key='', token_secret='', use_for_tweet=True, base_url=''): + self.consumer_key = consumer_key + self.consumer_secret = consumer_secret + self.token_key = token_key + self.token_secret = token_secret + self.use_for_tweet = use_for_tweet + self.base_url = base_url + self.name = name + +class KhweeteurPref(QMainWindow): + save = Signal() + + DEFAULTTHEME = u'Default' + WHITETHEME = u'White' + COOLWHITETHEME = u'CoolWhite' + COOLGRAYTHEME = u'CoolGray' + MINITHEME = u'MiniDefault' + THEMES = [DEFAULTTHEME, WHITETHEME, COOLWHITETHEME, COOLGRAYTHEME, MINITHEME] + + def __init__(self, parent=None): + ''' Init the GUI Win''' + QMainWindow.__init__(self,parent) + self.parent = parent + + self.settings = QSettings() + + self.setAttribute(Qt.WA_Maemo5AutoOrientation, True) + self.setAttribute(Qt.WA_Maemo5StackedWindow, True) + self.setAttribute(Qt.WA_DeleteOnClose, True) + self.setWindowTitle("Khweeteur Prefs") + + self._setupGUI() + self.loadPrefs() + + def loadPrefs(self): + ''' Load and init default prefs to GUI''' + #load account + self.accounts = [] + nb_accounts = self.settings.beginReadArray('accounts') + for index in range(nb_accounts): + self.settings.setArrayIndex(index) + self.accounts.append(KhweeteurAccount(name=self.settings.value('name'), \ + consumer_key=self.settings.value('consumer_key'), \ + consumer_secret=self.settings.value('consumer_secret'), \ + token_key=self.settings.value('token_key'), \ + token_secret=self.settings.value('token_secret'), \ + use_for_tweet=self.settings.value('use_for_tweet'), \ + base_url=self.settings.value('base_url'))) + self.settings.endArray() + self.accounts_model.set(self.accounts) + + #load other prefs + if self.settings.contains('refresh_interval'): + self.refresh_value.setValue(int(self.settings.value("refreshInterval"))) + else: + self.refresh_value.setValue(10) + + if self.settings.contains("useDaemon"): + self.useNotification_value.setCheckState(Qt.CheckState(int(self.settings.value("useDaemon")))) + else: + self.useNotification_value.setCheckState(Qt.CheckState(2)) + + if self.settings.contains("useSerialization"): + self.useSerialization_value.setCheckState(Qt.CheckState(int(self.settings.value("useSerialization")))) + else: + self.useSerialization_value.setCheckState(Qt.CheckState(2)) + + if self.settings.contains("useBitly"): + self.useBitly_value.setCheckState(Qt.CheckState(int(self.settings.value("useBitly")))) + else: + self.useBitly_value.setCheckState(Qt.CheckState(2)) + + if self.settings.contains("theme"): + if not self.settings.value("theme") in self.THEMES: + self.settings.setValue("theme",KhweeteurPref.DEFAULTTHEME) + else: + self.settings.setValue("theme",KhweeteurPref.DEFAULTTHEME) + + self.theme_value.setCurrentIndex(self.THEMES.index(self.settings.value("theme"))) + + if self.settings.contains("tweetHistory"): + self.history_value.setValue(int(self.settings.value("tweetHistory"))) + else: + self.history_value.setValue(60) + + if self.settings.contains("useGPS"): + self.useGPS_value.setCheckState(Qt.CheckState(int(self.settings.value("useGPS")))) + else: + self.useGPS_value.setCheckState(Qt.CheckState(2)) + + def savePrefs(self): + ''' Save the prefs from the GUI to QSettings''' + self.settings.beginWriteArray("accounts") + for index,account in enumerate(self.accounts): + self.settings.setArrayIndex(index) + self.settings.setValue("name", account.name) + self.settings.setValue("consumer_key", account.consumer_key) + self.settings.setValue("consumer_secret", account.consumer_secret) + self.settings.setValue("token_key", account.token_key) + self.settings.setValue("token_secret", account.token_secret) + self.settings.setValue("use_for_tweet", account.use_for_tweet) + self.settings.setValue("base_url", account.base_url) + self.settings.endArray() + + self.settings.setValue('refreshInterval', self.refresh_value.value()) + self.settings.setValue('useDaemon', self.useNotification_value.checkState()) + self.settings.setValue('useSerialization', self.useSerialization_value.checkState()) + self.settings.setValue('useBitly', self.useBitly_value.checkState()) + self.settings.setValue('theme', self.theme_value.currentText()) + self.settings.setValue('useGPS', self.useGPS_value.checkState()) + self.settings.setValue('tweetHistory', self.history_value.value()) + self.settings.sync() + self.save.emit() + + def add_account(self): + self.dlg = AccountDlg() + self.dlg.add_account.connect(self.do_ask_token) + self.dlg.show() + + def do_verify_pin(self,pincode): + token = oauth.Token(self.oauth_webview.request_token['oauth_token'][0], self.oauth_webview.request_token['oauth_token_secret'][0]) + token.set_verifier(unicode(pincode.strip())) + + signature_method_hmac_sha1 = oauth.SignatureMethod_HMAC_SHA1() + oauth_consumer = oauth.Consumer(key=self.oauth_webview.account_type['consumer_key'], secret=self.oauth_webview.account_type['consumer_secret']) + oauth_client = oauth.Client(oauth_consumer) + + + try: + oauth_client = oauth.Client(oauth_consumer, token) + resp, content = oauth_client.request(self.oauth_webview.account_type['access_token_url'], method='POST', body='oauth_verifier=%s' % str(pincode.strip())) + access_token = (parse_qs(content)) + + print access_token['oauth_token'][0] + + if resp['status'] == '200': + #Create the account + self.accounts.append(KhweeteurAccount(\ + name=self.oauth_webview.account_type['name'],\ + base_url=self.oauth_webview.account_type['base_url'],\ + consumer_key=self.oauth_webview.account_type['consumer_key'],\ + consumer_secret=self.oauth_webview.account_type['consumer_secret'],\ + token_key=access_token['oauth_token'][0],\ + token_secret=access_token['oauth_token_secret'][0],\ + use_for_tweet=self.oauth_webview.use_for_tweet,\ + )) + self.accounts_model.set(self.accounts) + self.savePrefs() + else: + KhweeteurNotification().warn(self.tr('Invalid respond from %s requesting access token: %s') % (self.oauth_webview.account_type['name'],resp['status'])) + except StandardError, err: + KhweeteurNotification().warn(self.tr('A error occur while requesting temp token : %s' % (err,))) + import traceback + traceback.print_exc() + + self.oauth_win.close() + del self.oauth_win + del self.oauth_webview + + def do_ask_token(self, account_type,use_for_tweet): + print account_type,use_for_tweet + + signature_method_hmac_sha1 = oauth.SignatureMethod_HMAC_SHA1() + oauth_consumer = oauth.Consumer(key=account_type['consumer_key'], secret=account_type['consumer_secret']) + oauth_client = oauth.Client(oauth_consumer) + + #Crappy hack for fixing oauth_callback not yet supported by the oauth2 lib but requested by identi.ca + body = 'oauth_callback=oob' + + try: + resp, content = oauth_client.request(account_type['request_token_url'], 'POST', body=body) + + if resp['status'] != '200': + KhweeteurNotification().warn(self.tr('Invalid respond from %s requesting temp token: %s') % (account_type['name'],resp['status'])) + else: + request_token = (parse_qs(content)) + + self.oauth_webview = OAuthView(self, account_type, use_for_tweet) + self.oauth_webview.open((QUrl('%s?oauth_token=%s' % (account_type['authorization_url'], request_token['oauth_token'][0])))) + self.oauth_webview.request_token = request_token + + self.oauth_webview.show() + self.oauth_webview.gotpin.connect(self.do_verify_pin) + self.oauth_win = QMainWindow(self) + self.oauth_win.setCentralWidget(self.oauth_webview) + self.oauth_win.setAttribute(Qt.WA_Maemo5AutoOrientation, True) + self.oauth_win.setAttribute(Qt.WA_Maemo5StackedWindow, True) + self.oauth_win.setWindowTitle("Khweeteur OAuth") + self.oauth_win.show() + + except httplib2.ServerNotFoundError,err: + KhweeteurNotification().warn(self.tr('Server not found : %s :') % unicode(err)) + + def delete_account(self, index): + if QMessageBox.question(self,'Delete account', 'Are you sure you want to delete this account ?', QMessageBox.Yes | QMessageBox.Close) == QMessageBox.Yes: + for index in self.accounts_view.selectedIndexes(): + del self.accounts[index.row()] + self.accounts_model.set(self.accounts) + + def closeEvent(self,widget,*args): + ''' close event called when closing window''' + self.savePrefs() + + def _setupGUI(self): + ''' Create the gui content of the window''' + self.scrollArea = QScrollArea(self) + self.scrollArea.setWidgetResizable(True) + self.aWidget = QWidget(self.scrollArea) + self.aWidget.setMinimumSize(480,1000) + self.aWidget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.scrollArea.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.scrollArea.setWidget(self.aWidget) + #Available on maemo but should be too on Meego + try: + scroller = self.scrollArea.property("kineticScroller") + scroller.setEnabled(True) + except: + pass + + self._main_layout = QVBoxLayout(self.aWidget) + self._umain_layout = QGridLayout() + self.aWidget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + self._umain_layout.addWidget(QLabel(self.tr('Refresh Interval (Minutes) :')),3,0) + self.refresh_value = QSpinBox() + self._umain_layout.addWidget(self.refresh_value,3,1) + + self._umain_layout.addWidget(QLabel(self.tr('Number of tweet to keep in the view :')),4,0) + self.history_value = QSpinBox() + self._umain_layout.addWidget(self.history_value,4,1) + + self._umain_layout.addWidget(QLabel(self.tr('Theme :')),5,0) + + self.theme_value = QComboBox() + self._umain_layout.addWidget(self.theme_value,5,1) + for theme in self.THEMES: + self.theme_value.addItem(theme) + + self._umain_layout.addWidget(QLabel(self.tr('Other preferences :')),9,0) + self.useNotification_value = QCheckBox(self.tr('Use Daemon')) + self._umain_layout.addWidget(self.useNotification_value,10,1) + + self.useSerialization_value = QCheckBox(self.tr('Use Serialization')) + self._umain_layout.addWidget(self.useSerialization_value,11,1) + + self.useBitly_value = QCheckBox(self.tr('Use Bit.ly')) + self._umain_layout.addWidget(self.useBitly_value,12,1) + + self.useGPS_value = QCheckBox(self.tr('Use GPS Geopositionning')) + self._umain_layout.addWidget(self.useGPS_value,13,1) + + self._main_layout.addLayout(self._umain_layout) + + self.accounts_model = AccountsModel() + self.accounts_view = AccountsView() + self.accounts_view.clicked.connect(self.delete_account) + self.accounts_view.setModel(self.accounts_model) + self.add_acc_button = QPushButton('Add account') + self.add_acc_button.clicked.connect(self.add_account) + self._main_layout.addWidget(self.add_acc_button) + self._main_layout.addWidget(self.accounts_view) + + self.aWidget.setLayout(self._main_layout) + self.setCentralWidget(self.scrollArea) + +if __name__ == '__main__': + import sys + app = QApplication(sys.argv) + app.setOrganizationName("Khertan Software") + app.setOrganizationDomain("khertan.net") + app.setApplicationName("Khweeteur") + + khtsettings = KhweeteurPref() + khtsettings.show() + sys.exit(app.exec_()) diff --git a/khweeteur/tweetslist.py b/khweeteur/tweetslist.py new file mode 100644 index 0000000..f8f5c2c --- /dev/null +++ b/khweeteur/tweetslist.py @@ -0,0 +1,205 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2010 Benoît HERVIER +# Licenced under GPLv3 + +'''A Twitter client made with PySide and QML''' + +from PySide.QtGui import * +from PySide.QtCore import * + +import os +import sys +import glob +import pickle +import time +import twitter +import dbus +import dbus.service + +AVATAR_CACHE_FOLDER = '/home/user/.khweeteur/avatars' + +class KhweeteurDBusHandler(dbus.service.Object): + def __init__(self): + dbus.service.Object.__init__(self, dbus.SessionBus(), '/net/khertan/Khweeteur/RequireUpdate') + + @dbus.service.signal(dbus_interface='net.khertan.Khweeteur', + signature='') + def require_update(self): + pass + +class StatusWrapper(QObject): + def __init__(self,status): + QObject.__init__(self) + self._status = status + + def _screen_name(self): + return self._status.user.screen_name + + def _id(self): + return self._status.id + + def _image_url(self): + return self._status.user.profile_image_url + + def _avatar(self): + return os.path.join(AVATAR_CACHE_FOLDER, \ + os.path.basename(self._status.user.profile_image_url.replace('/' , '_'))) + + def _text(self): + return self._status.text + + def _created_at(self): + return self._status.created_at + + def _in_reply_to_screenname(self): + return self._status.in_reply_to_screenname + + @Signal + def changed(self): + pass + + def __cmp__(obj1,obj2): + if obj1._status.created_at == obj2._status.created_at: + return 0 + if obj1._status.created_at > obj2._status.created_at: + return -1 + else: + return 1 + + screen_name = Property(unicode, _screen_name, notify=changed) + id = Property(unicode, _id, notify=changed) + image_url = Property(unicode, _image_url, notify=changed) + avatar = Property(unicode, _avatar, notify=changed) + text = Property(unicode, _text, notify=changed) + created_at = Property(unicode, _created_at, notify=changed) + in_reply_to_screenname = Property(unicode, _in_reply_to_screenname, notify=changed) + +class TweetsListModel(QAbstractListModel): +# dataChanged = Signal(QModelIndex,QModelIndex) + + COLUMNS = ('status',) + + def __init__(self, statuses = []): + QAbstractListModel.__init__(self) + self._statuses = statuses + self.setRoleNames(dict(enumerate(TweetsListModel.COLUMNS))) + + def rowCount(self,parent=QModelIndex): + return len(self._statuses) + + def data(self,index,role): + if index.isValid(): + return self._statuses[index.row()] + else: + return None + + @Slot(unicode) + def load_list(self,tweetlist): + print 'load timeline tweets' + start = time.time() + TIMELINE_PATH = '/home/user/.khweeteur/cache/%s' % (tweetlist) + cach_path = TIMELINE_PATH + uids = glob.glob(cach_path + '/*')[:60] + statuses = [] + for uid in uids: + uid = os.path.basename(uid) + try: + pkl_file = open(os.path.join(cach_path, uid), 'rb') + status = pickle.load(pkl_file) + pkl_file.close() + statuses.append(status) + except: + pass + print time.time() - start + print len(statuses) + self._statuses = [StatusWrapper(status) for status in statuses] + self._statuses.sort() + + #FIXME + #Wait pyside bug is resolved + self.dataChanged.emit(self.createIndex(0, 1), + self.createIndex(0, + len(self._statuses))) + + +class ButtonWrapper(QObject): + def __init__(self,button): + QObject.__init__(self) + self._button = button + + def _label(self): + return self._button['label'] + + def _count(self): + return self._button['count'] + + def _src(self): + return self._button['src'] + + @Signal + def changed(self): + pass + + label = Property(unicode, _label, notify=changed) + src = Property(unicode, _src, notify=changed) + count = Property(int, _count, notify=changed) + +class ToolbarListModel(QAbstractListModel): + COLUMNS = ('button',) + def __init__(self,): + QAbstractListModel.__init__(self) + self._buttons = [] + self._buttons.append(ButtonWrapper({'label':'','src':'refresh.png','count':0})) + self._buttons.append(ButtonWrapper({'label':'Timeline','src':'','count':0})) + self._buttons.append(ButtonWrapper({'label':'Mentions','src':'','count':0})) + self._buttons.append(ButtonWrapper({'label':'DMs','src':'','count':0})) + self.setRoleNames(dict(enumerate(ToolbarListModel.COLUMNS))) + + def rowCount(self,parent=QModelIndex): + return len(self._buttons) + + def data(self,index,role): + if index.isValid(): + return self._buttons[index.row()] + else: + return None + + def setCount(self,msg,count): + for button in self._buttons: + if button._button['label'] == msg: + button._button['count'] = int(count) + + #FIXME + #Wait pyside bug is resolved +# self.dataChanged.emit(self.createIndex(0, 1), +# self.createIndex(0, +# len(self._statuses))) + +class Controller(QObject): + switch_fullscreen = Signal() + switch_list = Signal(unicode) + + def __init__(self): + QObject.__init__(self,None) + self.dbus_handler = KhweeteurDBusHandler() + + @Slot(QObject) + def statusSelected(self, wrapper): + print 'User clicked on:', wrapper._status.id + + @Slot(unicode) + def toolbar_callback(self,name): + print name + if name.endswith('fullsize.png'): + self.switch_fullscreen.emit() + elif name.endswith('Timeline'): + QApplication.processEvents() + self.switch_list.emit('HomeTimeline') + elif name.endswith('Mentions'): + QApplication.processEvents() + self.switch_list.emit('Mentions') + elif name.endswith('refresh.png'): + QApplication.processEvents() + self.dbus_handler.require_update() diff --git a/khweeteur/twitpic.py b/khweeteur/twitpic.py new file mode 100644 index 0000000..564f9ef --- /dev/null +++ b/khweeteur/twitpic.py @@ -0,0 +1,451 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +""" +# python-twitpic - Dead-simple Twitpic image uploader. + +# Copyright (c) 2009, Chris McMichael +# http://chrismcmichael.com/ +# http://code.google.com/p/python-twitpic/ + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author nor the names of its contributors may +# be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS``AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE FOR ANY +# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +""" +import mimetypes +import os +import urllib +import urllib2 +import re + +from oauth import oauth +from xml.dom import minidom as xml +from xml.parsers.expat import ExpatError + +try: + import cStringIO as StringIO +except ImportError: + import StringIO + +try: + import json +except ImportError: + import simplejson as json + + +class TwitPicError(Exception): + """TwitPic Exception""" + + def __init__(self, reason, response=None): + self.reason = unicode(reason) + self.response = response + + def __str__(self): + return self.reason + + +# Handles Twitter OAuth authentication +class TwitPicOAuthClient(oauth.OAuthClient): + """TwitPic OAuth Client API""" + + SIGNIN_URL = 'https://api.twitter.com/oauth/authenticate' + STATUS_UPDATE_URL = 'https://api.twitter.com/1/statuses/update.json' + USER_INFO_URL = 'https://api.twitter.com/1/account/verify_credentials.json' + + FORMAT = 'json' + SERVER = 'http://api.twitpic.com' + + GET_URIS = { + 'media_show': ('/2/media/show', ('id',)), + 'faces_show': ('/2/faces/show', ('user')), + 'user_show': ('/2/users/show', ('username',)), # additional optional params + 'comments_show': ('/2/comments/show', ('media_id', 'page')), + 'place_show': ('/2/place/show', ('id',)), + 'places_user_show': ('/2/places/show', ('user',)), + 'events_show': ('/2/events/show', ('user',)), + 'event_show': ('/2/event/show', ('id',)), + 'tags_show': ('/2/tags/show', ('tag',)), + } + + POST_URIS = { + 'upload': ('/2/upload', ('message', 'media')), + 'comments_create': ('/2/comments/create', ('message_id', 'message')), + 'faces_create': ('/2/faces/create', ('media_id', 'top_coord', 'left_coord')), # additional optional params + 'event_create': ('/2/event/create', ('name')), # additional optional params + 'event_add': ('/2/event/add', ('event_id', 'media_id')), # no workie! + 'tags_create': ('/2/tags/create', ('media_id', 'tags')), + } + + PUT_URIS = { + 'faces_edit': ('/2/faces/edit', ('tag_id', 'top_coord', 'left_coord')), + } + + DELETE_URIS = { + 'comments_delete': ('/2/comments/delete', ('comment_id')), + 'faces_delete': ('/2/faces/delete', ('tag_id')), + 'event_delete': ('/2/event/delete', ('event_id')), + 'event_remove': ('/2/event/remove', ('event_id', 'media_id')), + 'tags_delete': ('/2/tags/delete', ('media_id', 'tag_id')), + } + + def __init__(self, consumer_key=None, consumer_secret=None, + service_key=None, access_token=None): + """ + An object for interacting with the Twitpic API. + + The arguments listed below are generally required for most calls. + + Args: + consumer_key: + Twitter API Key [optional] + consumer_secret: + Twitter API Secret [optional] + access_token: + Authorized access_token in string format. [optional] + service_key: + Twitpic service key used to interact with the API. [optional] + + NOTE: + The TwitPic OAuth Client does NOT support fetching + an access_token. Use your favorite Twitter API Client to + retrieve this. + + """ + self.server = self.SERVER + self.consumer = oauth.OAuthConsumer(consumer_key, consumer_secret) + self.signature_method = oauth.OAuthSignatureMethod_HMAC_SHA1() + self.service_key = service_key + self.format = self.FORMAT + + if access_token: + self.access_token = oauth.OAuthToken.from_string(access_token) + + def set_comsumer(self, consumer_key, consumer_secret): + self.consumer = oauth.OAuthConsumer(consumer_key, consumer_secret) + + def set_access_token(self, accss_token): + self.access_token = oauth.OAuthToken.from_string(access_token) + + def set_service_key(self, service_key): + self.service_key = service_key + + def _encode_multipart_formdata(self, fields=None): + BOUNDARY = '-------tHISiStheMulTIFoRMbOUNDaRY' + CRLF = '\r\n' + L = [] + filedata = None + media = fields.get('media', '') + + if media: + filedata = self._get_filedata(media) + del fields['media'] + + if fields: + for (key, value) in fields.items(): + L.append('--' + BOUNDARY) + L.append('Content-Disposition: form-data; name="%s"' % str(key)) + L.append('') + L.append(str(value)) + + if filedata: + for (filename, value) in [(media, filedata)]: + L.append('--' + BOUNDARY) + L.append('Content-Disposition: form-data; name="media"; \ + filename="%s"' % (str(filename),)) + L.append('Content-Type: %s' % self._get_content_type(media)) + L.append('') + L.append(value.getvalue()) + + L.append('--' + BOUNDARY + '--') + L.append('') + body = CRLF.join(L) + content_type = 'multipart/form-data; boundary=%s' % BOUNDARY + + return content_type, body + + def _get_content_type(self, media): + return mimetypes.guess_type(media)[0] or 'application/octet-stream' + + def _get_filedata(self, media): + # Check self.image is an url, file path, or nothing. + prog = re.compile('((https?|ftp|gopher|telnet|file|notes|ms-help):((//)|(\\\\))+[\w\d:#@%/;$()~_?\+-=\\\.&]*)') + + if prog.match(media): + return StringIO.StringIO(urllib2.urlopen(media).read()) + elif os.path.exists(media): + return StringIO.StringIO(open(media, 'rb').read()) + else: + return None + + def _post_call(self, method, params, uri, required): + if not self.consumer: + raise TwitPicError("Missing Twitter consumer keys") + if not self.access_token: + raise TwitPicError("Missing access_token") + if not self.service_key: + raise TwitPicError("Missing TwitPic service key") + + for req_param in required: + if req_param not in params: + raise TwitPicError('"' + req_param + '" parameter is not provided.') + + oauth_request = oauth.OAuthRequest.from_consumer_and_token( + self.consumer, + self.access_token, + http_url=self.USER_INFO_URL + ) + + # Sign our request before setting Twitpic-only parameters + oauth_request.sign_request(self.signature_method, self.consumer, self.access_token) + + # Set TwitPic parameters + oauth_request.set_parameter('key', self.service_key) + + for key, value in params.iteritems(): + oauth_request.set_parameter(key, value) + + # Build request body parameters. + params = oauth_request.parameters + content_type, content_body = self._encode_multipart_formdata(params) + + # Get the oauth headers. + oauth_headers = oauth_request.to_header(realm='http://api.twitter.com/') + + # Add the headers required by TwitPic and any additional headers. + headers = { + 'X-Verify-Credentials-Authorization': oauth_headers['Authorization'], + 'X-Auth-Service-Provider': self.USER_INFO_URL, + 'User-Agent': 'Python-TwitPic2', + 'Content-Type': content_type + } + + # Build our url + url = '%s%s.%s' % (self.server, uri, self.format) + + # Make the request. + + req = urllib2.Request(url, content_body, headers) + + try: + # Get the response. + response = urllib2.urlopen(req) + except urllib2.HTTPError, e: + raise TwitPicError(e) + + if self.format == 'json': + return self.parse_json(response.read()) + elif self.format == 'xml': + return self.parse_xml(response.read()) + + def read(self, method, params, format=None): + """ + Use this method for all GET URI calls. + + An access_token or service_key is not required for this method. + + Args: + method: + name that references which GET URI to use. + params: + dictionary of parameters needed for the selected method call. + format: + response format. default is json. options are (xml, json) + + NOTE: + faces_show is actually a POST method. However, since data + is being retrieved and not created, it seemed appropriate + to keep this call under the GET method calls. Tokens and keys + will be required for this call as well. + + """ + uri, required = self.GET_URIS.get(method, (None, None)) + if uri is None: + raise TwitPicError('Unidentified Method: ' + method) + + if format: + self.format = format + + if method == 'faces_show': + return self._post_call(method, params, uri, required) + + for req_param in required: + if req_param not in params: + raise TwitPicError('"' + req_param + '" parameter is not provided.') + + # Build our GET url + request_params = urllib.urlencode(params) + url = '%s%s.%s?%s' % (self.server, uri, self.format, request_params) + + # Make the request. + req = urllib2.Request(url) + + try: + # Get the response. + response = urllib2.urlopen(req) + except urllib2.HTTPError, e: + raise TwitPicError(e) + + if self.format == 'json': + return self.parse_json(response.read()) + elif self.format == 'xml': + return self.parse_xml(response.read()) + + def create(self, method, params, format=None): + """ + Use this method for all POST URI calls. + + Args: + method: + name that references which POST URI to use. + params: + dictionary of parameters needed for the selected method call. + format: + response format. default is json. options are (xml, json) + + NOTE: + You do NOT have to pass the key param (service key). Service key + should have been provided before calling this method. + + """ + if 'key' in params: + raise TwitPicError('"key" parameter should be provided by set_service_key method or initializer method.') + + uri, required = self.POST_URIS.get(method, (None, None)) + + if uri is None: + raise TwitPicError('Unidentified Method: ' + method) + + if format: + self.format = format + + return self._post_call(method, params, uri, required) + + def update(self, method, params, format=None): + """ + Use this method for all PUT URI calls. + + Args: + method: + name that references which PUT URI to use. + params: + dictionary of parameters needed for the selected method call. + format: + response format. default is json. options are (xml, json) + + """ + if 'key' in params: + raise TwitPicError('"key" parameter should be provided by set_service_key method or initializer method.') + + uri, required = self.PUT_URIS.get(method, (None, None)) + + if uri is None: + raise TwitPicError('Unidentified Method: ' + method) + + if format: + self.format = format + + return self._post_call(method, params, uri, required) + + def remove(self, method, params, format=None): + """ + Use this method for all DELETE URI calls. + + Args: + method: + name that references which DELETE URI to use. + params: + dictionary of parameters needed for the selected method call. + format: + response format. default is json. options are (xml, json) + + """ + if 'key' in params: + raise TwitPicError('"key" parameter should be provided by set_service_key method or initializer method.') + + uri, required = self.DELETE_URIS.get(method, (None, None)) + + if uri is None: + raise TwitPicError('Unidentified Method: ' + method) + + if format: + self.format = format + + return self._post_call(method, params, uri, required) + + def parse_xml(self, xml_response): + try: + dom = xml.parseString(xml_response) + node = dom.firstChild + if node.nodeName == 'errors': + return node.firstChild.nodeValue + else: + return dom + except ExpatError, e: + raise TwitPicError('XML Parsing Error: ' + e) + + def parse_json(self, json_response): + try: + result = json.loads(json_response) + if result.has_key('errors'): + return result['errors']['code'] + else: + return result + except ValueError, e: + raise TwitPicError('JSON Parsing Error: ' + e) + +if __name__ == '__main__': + from optparse import OptionParser + optPsr = OptionParser("usage: %prog -k CONSUMER_KEY -s CONSUMER_SECRET -a ACCESS_TOKEN -t SERVICE_KEY -m TWEET -f FILE") + optPsr.add_option('-k', '--consumer_key', type='string', help='Twitter consumer API key') + optPsr.add_option('-s', '--consumer_secret', type='string', help='Twitter consumer API secret') + optPsr.add_option('-a', '--access_token', type='string', help='Twitter Access Token') + optPsr.add_option('-t', '--service_key', type='string', help='Twitpic API key') + optPsr.add_option('-m', '--message', type='string', help='The tweet that belongs to the image.') + optPsr.add_option('-f', '--file', type='string', help='The file upload data.') + (opts, args) = optPsr.parse_args() + + if not opts.consumer_key: + optPsr.error("Missing CONSUMER_KEY") + + if not opts.consumer_secret: + optPsr.error("Missing CONSUMER_SECRET") + + if not opts.access_token: + optPsr.error("Missing ACCESS_TOKEN") + + if not opts.service_key: + optPsr.error("Missing SERVICE_KEY") + + if not opts.message: + optPsr.error("Missing TWEET") + + if not opts.file: + optPsr.error("Missing FILE") + + twitpic = TwitPicOAuthClient( + consumer_key = opts.consumer_key, + consumer_secret = opts.consumer_secret, + access_token = opts.access_token, + service_key = opts.service_key, + ) + + response = twitpic.create('upload', {'media': opts.file, 'message': opts.message }) + print response diff --git a/khweeteur/twitter.py b/khweeteur/twitter.py new file mode 100644 index 0000000..dfb8674 --- /dev/null +++ b/khweeteur/twitter.py @@ -0,0 +1,3597 @@ +#!/usr/bin/python2.4 +# -*- coding: utf-8 -*- +# Copyright 2007 The Python-Twitter Developers +# This version is a fork made by Khertan +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +'''A library that provides a Python interface to the Twitter API''' + +__author__ = 'python-twitter@googlegroups.com' +__version__ = '0.8-khtfork.3' + + +import base64 +import calendar +import datetime +import httplib +import os +import rfc822 +import simplejson +import sys +import tempfile +import textwrap +import time +import urllib +import urllib2 +import urlparse +import gzip +import StringIO + +import oauth2 as oauth + +import socket +socket.setdefaulttimeout(120) + +# parse_qsl moved to urlparse module in v2.6 +try: + from urlparse import parse_qsl, parse_qs +except: + from cgi import parse_qsl, parse_qs + +try: + from hashlib import md5 +except ImportError: + from md5 import md5 + + +CHARACTER_LIMIT = 140 + +# A singleton representing a lazily instantiated FileCache. +DEFAULT_CACHE = object() + +REQUEST_TOKEN_URL = 'https://api.twitter.com/oauth/request_token' +ACCESS_TOKEN_URL = 'https://api.twitter.com/oauth/access_token' +AUTHORIZATION_URL = 'https://api.twitter.com/oauth/authorize' +SIGNIN_URL = 'https://api.twitter.com/oauth/authenticate' + + + +import re, htmlentitydefs + +## +# Removes HTML or XML character references and entities from a text string. +# +# @param text The HTML (or XML) source text. +# @return The plain text, as a Unicode string, if necessary. + +def unescape(text): + def fixup(m): + text = m.group(0) + if text[:2] == "&#": + # character reference + try: + if text[:3] == "&#x": + return unichr(int(text[3:-1], 16)) + else: + return unichr(int(text[2:-1])) + except ValueError: + pass + else: + # named entity + try: + text = unichr(htmlentitydefs.name2codepoint[text[1:-1]]) + except KeyError: + pass + return text # leave as is + return re.sub("&#?\w+;", fixup, text) + +class TwitterError(Exception): + '''Base class for Twitter errors''' + + @property + def message(self): + '''Returns the first argument used to construct this error.''' + return self.args[0] + + +class Status(object): + '''A class representing the Status structure used by the twitter API. + + The Status structure exposes the following properties: + + status.created_at + status.created_at_in_seconds # read only + status.favorited + status.in_reply_to_screen_name + status.in_reply_to_user_id + status.in_reply_to_status_id + status.truncated + status.source + status.id + status.text + status.location + status.relative_created_at # read only + status.user + ''' + def __init__(self, + created_at=None, + favorited=None, + id=None, + text=None, + location=None, + user=None, + in_reply_to_screen_name=None, + in_reply_to_user_id=None, + in_reply_to_status_id=None, + in_reply_to_status_text=None, + truncated=None, + source=None, + now=None, + origin=None, + retweeted_status=None): + '''An object to hold a Twitter status message. + + This class is normally instantiated by the twitter.Api class and + returned in a sequence. + + Note: Dates are posted in the form "Sat Jan 27 04:17:38 +0000 2007" + + Args: + created_at: The time this status message was posted + favorited: Whether this is a favorite of the authenticated user + id: The unique id of this status message + text: The text of this status message + location: the geolocation string associated with this message + relative_created_at: + A human readable string representing the posting time + user: + A twitter.User instance representing the person posting the message + now: + The current time, if the client choses to set it. Defaults to the + wall clock time. + ''' + self.created_at = created_at + self.favorited = favorited + self.id = id + self.text = text + self.location = location + self.user = user + self.now = now + self.in_reply_to_screen_name = in_reply_to_screen_name + self.in_reply_to_user_id = in_reply_to_user_id + self.in_reply_to_status_id = in_reply_to_status_id + self.in_reply_to_status_text = in_reply_to_status_text + self.truncated = truncated + self.source = source + self.origin = origin + self.rel_created_at = None + self.retweeted_status = retweeted_status + + def GetCreatedAt(self): + '''Get the time this status message was posted. + + Returns: + The time this status message was posted + ''' + return self._created_at + + def SetCreatedAt(self, created_at): + '''Set the time this status message was posted. + + Args: + created_at: The time this status message was created + ''' + self._created_at = created_at + + created_at = property(GetCreatedAt, SetCreatedAt, + doc='The time this status message was posted.') + + def GetCreatedAtInSeconds(self): + '''Get the time this status message was posted, in seconds since the epoch. + + Returns: + The time this status message was posted, in seconds since the epoch. + ''' + return calendar.timegm(rfc822.parsedate(self.created_at)) + + created_at_in_seconds = property(GetCreatedAtInSeconds, + doc="The time this status message was " + "posted, in seconds since the epoch") + + def GetFavorited(self): + '''Get the favorited setting of this status message. + + Returns: + True if this status message is favorited; False otherwise + ''' + return self._favorited + + def SetFavorited(self, favorited): + '''Set the favorited state of this status message. + + Args: + favorited: boolean True/False favorited state of this status message + ''' + self._favorited = favorited + + favorited = property(GetFavorited, SetFavorited, + doc='The favorited state of this status message.') + + def GetId(self): + '''Get the unique id of this status message. + + Returns: + The unique id of this status message + ''' + return self._id + + def SetId(self, id): + '''Set the unique id of this status message. + + Args: + id: The unique id of this status message + ''' + self._id = id + + id = property(GetId, SetId, + doc='The unique id of this status message.') + + def GetInReplyToScreenName(self): + return self._in_reply_to_screen_name + + def SetInReplyToScreenName(self, in_reply_to_screen_name): + self._in_reply_to_screen_name = in_reply_to_screen_name + + in_reply_to_screen_name = property(GetInReplyToScreenName, SetInReplyToScreenName, + doc='') + + def GetInReplyToUserId(self): + return self._in_reply_to_user_id + + def SetInReplyToUserId(self, in_reply_to_user_id): + self._in_reply_to_user_id = in_reply_to_user_id + + in_reply_to_user_id = property(GetInReplyToUserId, SetInReplyToUserId, + doc='') + + def GetInReplyToStatusId(self): + return self._in_reply_to_status_id + + def SetInReplyToStatusId(self, in_reply_to_status_id): + self._in_reply_to_status_id = in_reply_to_status_id + + in_reply_to_status_id = property(GetInReplyToStatusId, SetInReplyToStatusId, + doc='') + + def GetTruncated(self): + return self._truncated + + def SetTruncated(self, truncated): + self._truncated = truncated + + truncated = property(GetTruncated, SetTruncated, + doc='') + + def GetSource(self): + return self._source + + def SetSource(self, source): + self._source = source + + source = property(GetSource, SetSource, + doc='') + + def GetText(self): + '''Get the text of this status message. + + Returns: + The text of this status message. + ''' + return self._text + + def SetText(self, text): + '''Set the text of this status message. + + Args: + text: The text of this status message + ''' + self._text = text + + text = property(GetText, SetText, + doc='The text of this status message') + + def GetLocation(self): + '''Get the geolocation associated with this status message + + Returns: + The geolocation string of this status message. + ''' + return self._location + + def SetLocation(self, location): + '''Set the geolocation associated with this status message + + Args: + location: The geolocation string of this status message + ''' + self._location = location + + location = property(GetLocation, SetLocation, + doc='The geolocation string of this status message') + + def GetRelativeCreatedAt(self,time_now=time.time()): + '''Get a human redable string representing the posting time + + Returns: + A human readable string representing the posting time + ''' + fudge = 1.25 + delta = long(time_now) - long(self.created_at_in_seconds) + + if delta < (1 * fudge): + return 'about a second ago' + elif delta < (60 * (1/fudge)): + return 'about %d seconds ago' % (delta) + elif delta < (60 * fudge): + return 'about a minute ago' + elif delta < (60 * 60 * (1/fudge)): + return 'about %d minutes ago' % (delta / 60) + elif delta < (60 * 60 * fudge) or delta / (60 * 60) == 1: + return 'about an hour ago' + elif delta < (60 * 60 * 24 * (1/fudge)): + return 'about %d hours ago' % (delta / (60 * 60)) + elif delta < (60 * 60 * 24 * fudge) or delta / (60 * 60 * 24) == 1: + return 'about a day ago' + else: + return 'about %d days ago' % (delta / (60 * 60 * 24)) + + relative_created_at = property(GetRelativeCreatedAt, + doc='Get a human readable string representing' + 'the posting time') + + def GetUser(self): + '''Get a twitter.User reprenting the entity posting this status message. + + Returns: + A twitter.User reprenting the entity posting this status message + ''' + return self._user + + def SetUser(self, user): + '''Set a twitter.User reprenting the entity posting this status message. + + Args: + user: A twitter.User reprenting the entity posting this status message + ''' + self._user = user + + user = property(GetUser, SetUser, + doc='A twitter.User reprenting the entity posting this ' + 'status message') + + def GetNow(self): + '''Get the wallclock time for this status message. + + Used to calculate relative_created_at. Defaults to the time + the object was instantiated. + + Returns: + Whatever the status instance believes the current time to be, + in seconds since the epoch. + ''' + if self._now is None: + self._now = time.time() + return self._now + + def SetNow(self, now): + '''Set the wallclock time for this status message. + + Used to calculate relative_created_at. Defaults to the time + the object was instantiated. + + Args: + now: The wallclock time for this instance. + ''' + self._now = now + + now = property(GetNow, SetNow, + doc='The wallclock time for this status instance.') + + def __cmp__(self,other): + if self.id == other.id: + return 0 + if self.created_at_in_seconds > other.created_at_in_seconds: + return -1 + return 1 + + def __lt__(self,other): + return other and self.created_at < other.created_at + + def __le__(self,other): + return other and self.created_at <= other.created_at + + def __gt__(self,other): + return other and self.created_at > other.created_at + + def __ge__(self,other): + return other and self.created_at >= other.created_at + + def __ne__(self, other): + return not self.__eq__(other) + + + def __eq__(self, other): + try: + return other and self.id == other.id + except AttributeError: + return False + + def __str__(self): + '''A string representation of this twitter.Status instance. + + The return value is the same as the JSON string representation. + + Returns: + A string representation of this twitter.Status instance. + ''' + return self.AsJsonString() + + def AsJsonString(self): + '''A JSON string representation of this twitter.Status instance. + + Returns: + A JSON string representation of this twitter.Status instance + ''' + return simplejson.dumps(self.AsDict(), sort_keys=True) + + def AsDict(self): + '''A dict representation of this twitter.Status instance. + + The return value uses the same key names as the JSON representation. + + Return: + A dict representing this twitter.Status instance + ''' + data = {} + if self.created_at: + data['created_at'] = self.created_at + if self.favorited: + data['favorited'] = self.favorited + if self.id: + data['id'] = self.id + if self.text: + data['text'] = self.text + if self.location: + data['location'] = self.location + if self.user: + data['user'] = self.user.AsDict() + if self.in_reply_to_screen_name: + data['in_reply_to_screen_name'] = self.in_reply_to_screen_name + if self.in_reply_to_user_id: + data['in_reply_to_user_id'] = self.in_reply_to_user_id + if self.in_reply_to_status_id: + data['in_reply_to_status_id'] = self.in_reply_to_status_id + if self.truncated is not None: + data['truncated'] = self.truncated + if self.favorited is not None: + data['favorited'] = self.favorited + if self.source: + data['source'] = self.source + return data + + @staticmethod + def NewFromJsonDict(data): + '''Create a new instance based on a JSON dict. + + Args: + data: A JSON dict, as converted from the JSON in the twitter API + Returns: + A twitter.Status instance + ''' + if 'retweeted_status' in data: + retweeted_status = Status.NewFromJsonDict(data['retweeted_status']) + else: + retweeted_status = None + + if 'user' in data: + user = User.NewFromJsonDict(data['user']) + else: + user = None + return Status(created_at=data.get('created_at', None), + favorited=data.get('favorited', None), + id=long(data.get('id', None)), + text=unescape(data.get('text', None)), + location=data.get('location', None), + in_reply_to_screen_name=data.get('in_reply_to_screen_name', None), + in_reply_to_user_id=data.get('in_reply_to_user_id', None), + in_reply_to_status_id=data.get('in_reply_to_status_id', None), + truncated=data.get('truncated', None), + source=data.get('source', None), + user=user, + retweeted_status=retweeted_status) + +class List(object): + '''A class representing the List structure used by the twitter API. + + The List structure exposes the following properties: + + list.id + list.name + list.slug + list.description + list.full_name + list.mode + list.uri + list.member_count + list.subscriber_count + list.following + ''' + def __init__(self, + id=None, + name=None, + slug=None, + description=None, + full_name=None, + mode=None, + uri=None, + member_count=None, + subscriber_count=None, + following=None, + user=None): + self.id = id + self.name = name + self.slug = slug + self.description = description + self.full_name = full_name + self.mode = mode + self.uri = uri + self.member_count = member_count + self.subscriber_count = subscriber_count + self.following = following + self.user = user + + def GetId(self): + '''Get the unique id of this list. + + Returns: + The unique id of this list + ''' + return self._id + + def SetId(self, id): + '''Set the unique id of this list. + + Args: + id: + The unique id of this list. + ''' + self._id = id + + id = property(GetId, SetId, + doc='The unique id of this list.') + + def GetName(self): + '''Get the real name of this list. + + Returns: + The real name of this list + ''' + return self._name + + def SetName(self, name): + '''Set the real name of this list. + + Args: + name: + The real name of this list + ''' + self._name = name + + name = property(GetName, SetName, + doc='The real name of this list.') + + def GetSlug(self): + '''Get the slug of this list. + + Returns: + The slug of this list + ''' + return self._slug + + def SetSlug(self, slug): + '''Set the slug of this list. + + Args: + slug: + The slug of this list. + ''' + self._slug = slug + + slug = property(GetSlug, SetSlug, + doc='The slug of this list.') + + def GetDescription(self): + '''Get the description of this list. + + Returns: + The description of this list + ''' + return self._description + + def SetDescription(self, description): + '''Set the description of this list. + + Args: + description: + The description of this list. + ''' + self._description = description + + description = property(GetDescription, SetDescription, + doc='The description of this list.') + + def GetFull_name(self): + '''Get the full_name of this list. + + Returns: + The full_name of this list + ''' + return self._full_name + + def SetFull_name(self, full_name): + '''Set the full_name of this list. + + Args: + full_name: + The full_name of this list. + ''' + self._full_name = full_name + + full_name = property(GetFull_name, SetFull_name, + doc='The full_name of this list.') + + def GetMode(self): + '''Get the mode of this list. + + Returns: + The mode of this list + ''' + return self._mode + + def SetMode(self, mode): + '''Set the mode of this list. + + Args: + mode: + The mode of this list. + ''' + self._mode = mode + + mode = property(GetMode, SetMode, + doc='The mode of this list.') + + def GetUri(self): + '''Get the uri of this list. + + Returns: + The uri of this list + ''' + return self._uri + + def SetUri(self, uri): + '''Set the uri of this list. + + Args: + uri: + The uri of this list. + ''' + self._uri = uri + + uri = property(GetUri, SetUri, + doc='The uri of this list.') + + def GetMember_count(self): + '''Get the member_count of this list. + + Returns: + The member_count of this list + ''' + return self._member_count + + def SetMember_count(self, member_count): + '''Set the member_count of this list. + + Args: + member_count: + The member_count of this list. + ''' + self._member_count = member_count + + member_count = property(GetMember_count, SetMember_count, + doc='The member_count of this list.') + + def GetSubscriber_count(self): + '''Get the subscriber_count of this list. + + Returns: + The subscriber_count of this list + ''' + return self._subscriber_count + + def SetSubscriber_count(self, subscriber_count): + '''Set the subscriber_count of this list. + + Args: + subscriber_count: + The subscriber_count of this list. + ''' + self._subscriber_count = subscriber_count + + subscriber_count = property(GetSubscriber_count, SetSubscriber_count, + doc='The subscriber_count of this list.') + + def GetFollowing(self): + '''Get the following status of this list. + + Returns: + The following status of this list + ''' + return self._following + + def SetFollowing(self, following): + '''Set the following status of this list. + + Args: + following: + The following of this list. + ''' + self._following = following + + following = property(GetFollowing, SetFollowing, + doc='The following status of this list.') + + def GetUser(self): + '''Get the user of this list. + + Returns: + The owner of this list + ''' + return self._user + + def SetUser(self, user): + '''Set the user of this list. + + Args: + user: + The owner of this list. + ''' + self._user = user + + user = property(GetUser, SetUser, + doc='The owner of this list.') + + def __ne__(self, other): + return not self.__eq__(other) + + def __eq__(self, other): + try: + return other and \ + self.id == other.id and \ + self.name == other.name and \ + self.slug == other.slug and \ + self.description == other.description and \ + self.full_name == other.full_name and \ + self.mode == other.mode and \ + self.uri == other.uri and \ + self.member_count == other.member_count and \ + self.subscriber_count == other.subscriber_count and \ + self.following == other.following and \ + self.user == other.user + + except AttributeError: + return False + + def __str__(self): + '''A string representation of this twitter.List instance. + + The return value is the same as the JSON string representation. + + Returns: + A string representation of this twitter.List instance. + ''' + return self.AsJsonString() + + def AsJsonString(self): + '''A JSON string representation of this twitter.List instance. + + Returns: + A JSON string representation of this twitter.List instance + ''' + return simplejson.dumps(self.AsDict(), sort_keys=True) + + def AsDict(self): + '''A dict representation of this twitter.List instance. + + The return value uses the same key names as the JSON representation. + + Return: + A dict representing this twitter.List instance + ''' + data = {} + if self.id: + data['id'] = self.id + if self.name: + data['name'] = self.name + if self.slug: + data['slug'] = self.slug + if self.description: + data['description'] = self.description + if self.full_name: + data['full_name'] = self.full_name + if self.mode: + data['mode'] = self.mode + if self.uri: + data['uri'] = self.uri + if self.member_count is not None: + data['member_count'] = self.member_count + if self.subscriber_count is not None: + data['subscriber_count'] = self.subscriber_count + if self.following is not None: + data['following'] = self.following + if self.user is not None: + data['user'] = self.user + return data + + @staticmethod + def NewFromJsonDict(data): + '''Create a new instance based on a JSON dict. + + Args: + data: + A JSON dict, as converted from the JSON in the twitter API + + Returns: + A twitter.List instance + ''' + if 'user' in data: + user = User.NewFromJsonDict(data['user']) + else: + user = None + return List(id=data.get('id', None), + name=data.get('name', None), + slug=data.get('slug', None), + description=data.get('description', None), + full_name=data.get('full_name', None), + mode=data.get('mode', None), + uri=data.get('uri', None), + member_count=data.get('member_count', None), + subscriber_count=data.get('subscriber_count', None), + following=data.get('following', None), + user=user) + +class User(object): + '''A class representing the User structure used by the twitter API. + + The User structure exposes the following properties: + + user.id + user.name + user.screen_name + user.location + user.description + user.profile_image_url + user.profile_background_tile + user.profile_background_image_url + user.profile_sidebar_fill_color + user.profile_background_color + user.profile_link_color + user.profile_text_color + user.protected + user.utc_offset + user.time_zone + user.url + user.status + user.statuses_count + user.followers_count + user.friends_count + user.favourites_count + ''' + def __init__(self, + id=None, + name=None, + screen_name=None, + location=None, + description=None, + profile_image_url=None, + profile_background_tile=None, + profile_background_image_url=None, + profile_sidebar_fill_color=None, + profile_background_color=None, + profile_link_color=None, + profile_text_color=None, + protected=None, + utc_offset=None, + time_zone=None, + followers_count=None, + friends_count=None, + statuses_count=None, + favourites_count=None, + url=None, + status=None): + self.id = id + self.name = name + self.screen_name = screen_name + self.location = location + self.description = description + self.profile_image_url = profile_image_url + self.profile_background_tile = profile_background_tile + self.profile_background_image_url = profile_background_image_url + self.profile_sidebar_fill_color = profile_sidebar_fill_color + self.profile_background_color = profile_background_color + self.profile_link_color = profile_link_color + self.profile_text_color = profile_text_color + self.protected = protected + self.utc_offset = utc_offset + self.time_zone = time_zone + self.followers_count = followers_count + self.friends_count = friends_count + self.statuses_count = statuses_count + self.favourites_count = favourites_count + self.url = url + self.status = status + + + def GetId(self): + '''Get the unique id of this user. + + Returns: + The unique id of this user + ''' + return self._id + + def SetId(self, id): + '''Set the unique id of this user. + + Args: + id: The unique id of this user. + ''' + self._id = id + + id = property(GetId, SetId, + doc='The unique id of this user.') + + def GetName(self): + '''Get the real name of this user. + + Returns: + The real name of this user + ''' + return self._name + + def SetName(self, name): + '''Set the real name of this user. + + Args: + name: The real name of this user + ''' + self._name = name + + name = property(GetName, SetName, + doc='The real name of this user.') + + def GetScreenName(self): + '''Get the short username of this user. + + Returns: + The short username of this user + ''' + return self._screen_name + + def SetScreenName(self, screen_name): + '''Set the short username of this user. + + Args: + screen_name: the short username of this user + ''' + self._screen_name = screen_name + + screen_name = property(GetScreenName, SetScreenName, + doc='The short username of this user.') + + def GetLocation(self): + '''Get the geographic location of this user. + + Returns: + The geographic location of this user + ''' + return self._location + + def SetLocation(self, location): + '''Set the geographic location of this user. + + Args: + location: The geographic location of this user + ''' + self._location = location + + location = property(GetLocation, SetLocation, + doc='The geographic location of this user.') + + def GetDescription(self): + '''Get the short text description of this user. + + Returns: + The short text description of this user + ''' + return self._description + + def SetDescription(self, description): + '''Set the short text description of this user. + + Args: + description: The short text description of this user + ''' + self._description = description + + description = property(GetDescription, SetDescription, + doc='The short text description of this user.') + + def GetUrl(self): + '''Get the homepage url of this user. + + Returns: + The homepage url of this user + ''' + return self._url + + def SetUrl(self, url): + '''Set the homepage url of this user. + + Args: + url: The homepage url of this user + ''' + self._url = url + + url = property(GetUrl, SetUrl, + doc='The homepage url of this user.') + + def GetProfileImageUrl(self): + '''Get the url of the thumbnail of this user. + + Returns: + The url of the thumbnail of this user + ''' + return self._profile_image_url + + def SetProfileImageUrl(self, profile_image_url): + '''Set the url of the thumbnail of this user. + + Args: + profile_image_url: The url of the thumbnail of this user + ''' + self._profile_image_url = profile_image_url + + profile_image_url= property(GetProfileImageUrl, SetProfileImageUrl, + doc='The url of the thumbnail of this user.') + + def GetProfileBackgroundTile(self): + '''Boolean for whether to tile the profile background image. + + Returns: + True if the background is to be tiled, False if not, None if unset. + ''' + return self._profile_background_tile + + def SetProfileBackgroundTile(self, profile_background_tile): + '''Set the boolean flag for whether to tile the profile background image. + + Args: + profile_background_tile: Boolean flag for whether to tile or not. + ''' + self._profile_background_tile = profile_background_tile + + profile_background_tile = property(GetProfileBackgroundTile, SetProfileBackgroundTile, + doc='Boolean for whether to tile the background image.') + + def GetProfileBackgroundImageUrl(self): + return self._profile_background_image_url + + def SetProfileBackgroundImageUrl(self, profile_background_image_url): + self._profile_background_image_url = profile_background_image_url + + profile_background_image_url = property(GetProfileBackgroundImageUrl, SetProfileBackgroundImageUrl, + doc='The url of the profile background of this user.') + + def GetProfileSidebarFillColor(self): + return self._profile_sidebar_fill_color + + def SetProfileSidebarFillColor(self, profile_sidebar_fill_color): + self._profile_sidebar_fill_color = profile_sidebar_fill_color + + profile_sidebar_fill_color = property(GetProfileSidebarFillColor, SetProfileSidebarFillColor) + + def GetProfileBackgroundColor(self): + return self._profile_background_color + + def SetProfileBackgroundColor(self, profile_background_color): + self._profile_background_color = profile_background_color + + profile_background_color = property(GetProfileBackgroundColor, SetProfileBackgroundColor) + + def GetProfileLinkColor(self): + return self._profile_link_color + + def SetProfileLinkColor(self, profile_link_color): + self._profile_link_color = profile_link_color + + profile_link_color = property(GetProfileLinkColor, SetProfileLinkColor) + + def GetProfileTextColor(self): + return self._profile_text_color + + def SetProfileTextColor(self, profile_text_color): + self._profile_text_color = profile_text_color + + profile_text_color = property(GetProfileTextColor, SetProfileTextColor) + + def GetProtected(self): + return self._protected + + def SetProtected(self, protected): + self._protected = protected + + protected = property(GetProtected, SetProtected) + + def GetUtcOffset(self): + return self._utc_offset + + def SetUtcOffset(self, utc_offset): + self._utc_offset = utc_offset + + utc_offset = property(GetUtcOffset, SetUtcOffset) + + def GetTimeZone(self): + '''Returns the current time zone string for the user. + + Returns: + The descriptive time zone string for the user. + ''' + return self._time_zone + + def SetTimeZone(self, time_zone): + '''Sets the user's time zone string. + + Args: + time_zone: The descriptive time zone to assign for the user. + ''' + self._time_zone = time_zone + + time_zone = property(GetTimeZone, SetTimeZone) + + def GetStatus(self): + '''Get the latest twitter.Status of this user. + + Returns: + The latest twitter.Status of this user + ''' + return self._status + + def SetStatus(self, status): + '''Set the latest twitter.Status of this user. + + Args: + status: The latest twitter.Status of this user + ''' + self._status = status + + status = property(GetStatus, SetStatus, + doc='The latest twitter.Status of this user.') + + def GetFriendsCount(self): + '''Get the friend count for this user. + + Returns: + The number of users this user has befriended. + ''' + return self._friends_count + + def SetFriendsCount(self, count): + '''Set the friend count for this user. + + Args: + count: The number of users this user has befriended. + ''' + self._friends_count = count + + friends_count = property(GetFriendsCount, SetFriendsCount, + doc='The number of friends for this user.') + + def GetFollowersCount(self): + '''Get the follower count for this user. + + Returns: + The number of users following this user. + ''' + return self._followers_count + + def SetFollowersCount(self, count): + '''Set the follower count for this user. + + Args: + count: The number of users following this user. + ''' + self._followers_count = count + + followers_count = property(GetFollowersCount, SetFollowersCount, + doc='The number of users following this user.') + + def GetStatusesCount(self): + '''Get the number of status updates for this user. + + Returns: + The number of status updates for this user. + ''' + return self._statuses_count + + def SetStatusesCount(self, count): + '''Set the status update count for this user. + + Args: + count: The number of updates for this user. + ''' + self._statuses_count = count + + statuses_count = property(GetStatusesCount, SetStatusesCount, + doc='The number of updates for this user.') + + def GetFavouritesCount(self): + '''Get the number of favourites for this user. + + Returns: + The number of favourites for this user. + ''' + return self._favourites_count + + def SetFavouritesCount(self, count): + '''Set the favourite count for this user. + + Args: + count: The number of favourites for this user. + ''' + self._favourites_count = count + + favourites_count = property(GetFavouritesCount, SetFavouritesCount, + doc='The number of favourites for this user.') + + def __ne__(self, other): + return not self.__eq__(other) + + def __eq__(self, other): + try: + return other and \ + self.id == other.id and \ + self.name == other.name and \ + self.screen_name == other.screen_name and \ + self.location == other.location and \ + self.description == other.description and \ + self.profile_image_url == other.profile_image_url and \ + self.profile_background_tile == other.profile_background_tile and \ + self.profile_background_image_url == other.profile_background_image_url and \ + self.profile_sidebar_fill_color == other.profile_sidebar_fill_color and \ + self.profile_background_color == other.profile_background_color and \ + self.profile_link_color == other.profile_link_color and \ + self.profile_text_color == other.profile_text_color and \ + self.protected == other.protected and \ + self.utc_offset == other.utc_offset and \ + self.time_zone == other.time_zone and \ + self.url == other.url and \ + self.statuses_count == other.statuses_count and \ + self.followers_count == other.followers_count and \ + self.favourites_count == other.favourites_count and \ + self.friends_count == other.friends_count and \ + self.status == other.status + except AttributeError: + return False + + def __str__(self): + '''A string representation of this twitter.User instance. + + The return value is the same as the JSON string representation. + + Returns: + A string representation of this twitter.User instance. + ''' + return self.AsJsonString() + + def AsJsonString(self): + '''A JSON string representation of this twitter.User instance. + + Returns: + A JSON string representation of this twitter.User instance + ''' + return simplejson.dumps(self.AsDict(), sort_keys=True) + + def AsDict(self): + '''A dict representation of this twitter.User instance. + + The return value uses the same key names as the JSON representation. + + Return: + A dict representing this twitter.User instance + ''' + data = {} + if self.id: + data['id'] = self.id + if self.name: + data['name'] = self.name + if self.screen_name: + data['screen_name'] = self.screen_name + if self.location: + data['location'] = self.location + if self.description: + data['description'] = self.description + if self.profile_image_url: + data['profile_image_url'] = self.profile_image_url + if self.profile_background_tile is not None: + data['profile_background_tile'] = self.profile_background_tile + if self.profile_background_image_url: + data['profile_sidebar_fill_color'] = self.profile_background_image_url + if self.profile_background_color: + data['profile_background_color'] = self.profile_background_color + if self.profile_link_color: + data['profile_link_color'] = self.profile_link_color + if self.profile_text_color: + data['profile_text_color'] = self.profile_text_color + if self.protected is not None: + data['protected'] = self.protected + if self.utc_offset: + data['utc_offset'] = self.utc_offset + if self.time_zone: + data['time_zone'] = self.time_zone + if self.url: + data['url'] = self.url + if self.status: + data['status'] = self.status.AsDict() + if self.friends_count: + data['friends_count'] = self.friends_count + if self.followers_count: + data['followers_count'] = self.followers_count + if self.statuses_count: + data['statuses_count'] = self.statuses_count + if self.favourites_count: + data['favourites_count'] = self.favourites_count + return data + + @staticmethod + def NewFromJsonDict(data): + '''Create a new instance based on a JSON dict. + + Args: + data: A JSON dict, as converted from the JSON in the twitter API + Returns: + A twitter.User instance + ''' + if 'status' in data: + status = Status.NewFromJsonDict(data['status']) + else: + status = None + return User(id=data.get('id', None), + name=data.get('name', None), + screen_name=data.get('screen_name', None), + location=data.get('location', None), + description=data.get('description', None), + statuses_count=data.get('statuses_count', None), + followers_count=data.get('followers_count', None), + favourites_count=data.get('favourites_count', None), + friends_count=data.get('friends_count', None), + profile_image_url=data.get('profile_image_url', None), + profile_background_tile = data.get('profile_background_tile', None), + profile_background_image_url = data.get('profile_background_image_url', None), + profile_sidebar_fill_color = data.get('profile_sidebar_fill_color', None), + profile_background_color = data.get('profile_background_color', None), + profile_link_color = data.get('profile_link_color', None), + profile_text_color = data.get('profile_text_color', None), + protected = data.get('protected', None), + utc_offset = data.get('utc_offset', None), + time_zone = data.get('time_zone', None), + url=data.get('url', None), + status=status) + +class DirectMessage(object): + '''A class representing the DirectMessage structure used by the twitter API. + + The DirectMessage structure exposes the following properties: + + direct_message.id + direct_message.created_at + direct_message.created_at_in_seconds # read only + direct_message.sender_id + direct_message.sender_screen_name + direct_message.recipient_id + direct_message.recipient_screen_name + direct_message.text + ''' + + def __init__(self, + id=None, + created_at=None, + sender_id=None, + sender_screen_name=None, + recipient_id=None, + recipient_screen_name=None, + text=None): + '''An object to hold a Twitter direct message. + + This class is normally instantiated by the twitter.Api class and + returned in a sequence. + + Note: Dates are posted in the form "Sat Jan 27 04:17:38 +0000 2007" + + Args: + id: The unique id of this direct message + created_at: The time this direct message was posted + sender_id: The id of the twitter user that sent this message + sender_screen_name: The name of the twitter user that sent this message + recipient_id: The id of the twitter that received this message + recipient_screen_name: The name of the twitter that received this message + text: The text of this direct message + ''' + self.id = id + self.created_at = created_at + self.sender_id = sender_id + self.sender_screen_name = sender_screen_name + self.recipient_id = recipient_id + self.recipient_screen_name = recipient_screen_name + self.text = text + + def GetId(self): + '''Get the unique id of this direct message. + + Returns: + The unique id of this direct message + ''' + return self._id + + def SetId(self, id): + '''Set the unique id of this direct message. + + Args: + id: The unique id of this direct message + ''' + self._id = id + + id = property(GetId, SetId, + doc='The unique id of this direct message.') + + def GetCreatedAt(self): + '''Get the time this direct message was posted. + + Returns: + The time this direct message was posted + ''' + return self._created_at + + def SetCreatedAt(self, created_at): + '''Set the time this direct message was posted. + + Args: + created_at: The time this direct message was created + ''' + self._created_at = created_at + + created_at = property(GetCreatedAt, SetCreatedAt, + doc='The time this direct message was posted.') + + def GetCreatedAtInSeconds(self): + '''Get the time this direct message was posted, in seconds since the epoch. + + Returns: + The time this direct message was posted, in seconds since the epoch. + ''' + return calendar.timegm(rfc822.parsedate(self.created_at)) + + created_at_in_seconds = property(GetCreatedAtInSeconds, + doc="The time this direct message was " + "posted, in seconds since the epoch") + + def GetRelativeCreatedAt(self,time_now=time.time()): + '''Get a human redable string representing the posting time + + Returns: + A human readable string representing the posting time + ''' + fudge = 1.25 + delta = long(time_now) - long(self.created_at_in_seconds) + + if delta < (1 * fudge): + return 'about a second ago' + elif delta < (60 * (1/fudge)): + return 'about %d seconds ago' % (delta) + elif delta < (60 * fudge): + return 'about a minute ago' + elif delta < (60 * 60 * (1/fudge)): + return 'about %d minutes ago' % (delta / 60) + elif delta < (60 * 60 * fudge) or delta / (60 * 60) == 1: + return 'about an hour ago' + elif delta < (60 * 60 * 24 * (1/fudge)): + return 'about %d hours ago' % (delta / (60 * 60)) + elif delta < (60 * 60 * 24 * fudge) or delta / (60 * 60 * 24) == 1: + return 'about a day ago' + else: + return 'about %d days ago' % (delta / (60 * 60 * 24)) + + relative_created_at = property(GetRelativeCreatedAt, + doc='Get a human readable string representing' + 'the posting time') + + def GetSenderId(self): + '''Get the unique sender id of this direct message. + + Returns: + The unique sender id of this direct message + ''' + return self._sender_id + + def SetSenderId(self, sender_id): + '''Set the unique sender id of this direct message. + + Args: + sender id: The unique sender id of this direct message + ''' + self._sender_id = sender_id + + sender_id = property(GetSenderId, SetSenderId, + doc='The unique sender id of this direct message.') + + def GetSenderScreenName(self): + '''Get the unique sender screen name of this direct message. + + Returns: + The unique sender screen name of this direct message + ''' + return self._sender_screen_name + + def SetSenderScreenName(self, sender_screen_name): + '''Set the unique sender screen name of this direct message. + + Args: + sender_screen_name: The unique sender screen name of this direct message + ''' + self._sender_screen_name = sender_screen_name + + sender_screen_name = property(GetSenderScreenName, SetSenderScreenName, + doc='The unique sender screen name of this direct message.') + + def GetRecipientId(self): + '''Get the unique recipient id of this direct message. + + Returns: + The unique recipient id of this direct message + ''' + return self._recipient_id + + def SetRecipientId(self, recipient_id): + '''Set the unique recipient id of this direct message. + + Args: + recipient id: The unique recipient id of this direct message + ''' + self._recipient_id = recipient_id + + recipient_id = property(GetRecipientId, SetRecipientId, + doc='The unique recipient id of this direct message.') + + def GetRecipientScreenName(self): + '''Get the unique recipient screen name of this direct message. + + Returns: + The unique recipient screen name of this direct message + ''' + return self._recipient_screen_name + + def SetRecipientScreenName(self, recipient_screen_name): + '''Set the unique recipient screen name of this direct message. + + Args: + recipient_screen_name: The unique recipient screen name of this direct message + ''' + self._recipient_screen_name = recipient_screen_name + + recipient_screen_name = property(GetRecipientScreenName, SetRecipientScreenName, + doc='The unique recipient screen name of this direct message.') + + def GetText(self): + '''Get the text of this direct message. + + Returns: + The text of this direct message. + ''' + return self._text + + def SetText(self, text): + '''Set the text of this direct message. + + Args: + text: The text of this direct message + ''' + self._text = text + + text = property(GetText, SetText, + doc='The text of this direct message') + + def __cmp__(self,other): + if self.id == other.id: + return 0 + if self.created_at < other.created_at: + return -1 + return 1 + + def __ne__(self, other): + return not self.__eq__(other) + + def __eq__(self, other): + try: + return other and \ + self.id == other.id and \ + self.created_at == other.created_at and \ + self.sender_id == other.sender_id and \ + self.sender_screen_name == other.sender_screen_name and \ + self.recipient_id == other.recipient_id and \ + self.recipient_screen_name == other.recipient_screen_name and \ + self.text == other.text + except AttributeError: + return False + + def __str__(self): + '''A string representation of this twitter.DirectMessage instance. + + The return value is the same as the JSON string representation. + + Returns: + A string representation of this twitter.DirectMessage instance. + ''' + return self.AsJsonString() + + def AsJsonString(self): + '''A JSON string representation of this twitter.DirectMessage instance. + + Returns: + A JSON string representation of this twitter.DirectMessage instance + ''' + return simplejson.dumps(self.AsDict(), sort_keys=True) + + def AsDict(self): + '''A dict representation of this twitter.DirectMessage instance. + + The return value uses the same key names as the JSON representation. + + Return: + A dict representing this twitter.DirectMessage instance + ''' + data = {} + if self.id: + data['id'] = self.id + if self.created_at: + data['created_at'] = self.created_at + if self.sender_id: + data['sender_id'] = self.sender_id + if self.sender_screen_name: + data['sender_screen_name'] = self.sender_screen_name + if self.recipient_id: + data['recipient_id'] = self.recipient_id + if self.recipient_screen_name: + data['recipient_screen_name'] = self.recipient_screen_name + if self.text: + data['text'] = self.text + return data + + @staticmethod + def NewFromJsonDict(data): + '''Create a new instance based on a JSON dict. + + Args: + data: A JSON dict, as converted from the JSON in the twitter API + Returns: + A twitter.DirectMessage instance + ''' + return DirectMessage(created_at=data.get('created_at', None), + recipient_id=data.get('recipient_id', None), + sender_id=data.get('sender_id', None), + text=data.get('text', None), + sender_screen_name=data.get('sender_screen_name', None), + id=data.get('id', None), + recipient_screen_name=data.get('recipient_screen_name', None)) + +class Api(object): + '''A python interface into the Twitter API + + By default, the Api caches results for 1 minute. + + Example usage: + + To create an instance of the twitter.Api class, with no authentication: + + >>> import twitter + >>> api = twitter.Api() + + To fetch the most recently posted public twitter status messages: + + >>> statuses = api.GetPublicTimeline() + >>> print [s.user.name for s in statuses] + [u'DeWitt', u'Kesuke Miyagi', u'ev', u'Buzz Andersen', u'Biz Stone'] #... + + To fetch a single user's public status messages, where "user" is either + a Twitter "short name" or their user id. + + >>> statuses = api.GetUserTimeline(user) + >>> print [s.text for s in statuses] + + To use authentication, instantiate the twitter.Api class with a + username, password and the oAuth key and secret: + + >>> api = twitter.Api(username='twitter user', password='twitter pass', + access_token_key='the_key_given', + access_token_secret='the_key_secret') + + To fetch your friends (after being authenticated): + + >>> users = api.GetFriends() + >>> print [u.name for u in users] + + To post a twitter status message (after being authenticated): + + >>> status = api.PostUpdate('I love python-twitter!') + >>> print status.text + I love python-twitter! + + There are many other methods, including: + + >>> api.PostUpdates(status) + >>> api.PostDirectMessage(user, text) + >>> api.GetUser(user) + >>> api.GetReplies() + >>> api.GetUserTimeline(user) + >>> api.GetStatus(id) + >>> api.DestroyStatus(id) + >>> api.GetFriendsTimeline(user) + >>> api.GetFriends(user) + >>> api.GetFollowers() + >>> api.GetFeatured() + >>> api.GetDirectMessages() + >>> api.PostDirectMessage(user, text) + >>> api.DestroyDirectMessage(id) + >>> api.DestroyFriendship(user) + >>> api.CreateFriendship(user) + >>> api.GetUserByEmail(email) + >>> api.VerifyCredentials() + ''' + + DEFAULT_CACHE_TIMEOUT = 60 # cache for 1 minute + _API_REALM = 'Twitter API' + + def __init__(self, + username=None, + password=None, + access_token_key=None, + access_token_secret=None, + input_encoding=None, + request_headers=None, + cache=DEFAULT_CACHE, + shortner=None, + base_url=None, + use_gzip_compression=False): + '''Instantiate a new twitter.Api object. + + Args: + username: + The username of the twitter account. [optional] + NOTE: for oAuth based authentication, this is not + optional and the value is the Twitter + Consumer Key value *not* your Twitter ID + password: + The password for the twitter account. [optional] + NOTE: for oAuth based authentication, this is not + optional and the value is the Twitter + Consumer Secret value *not* your Twitter + password + access_token_key: + The oAuth access token key value you retrieved + from running get_access_token.py. + access_token_secret: + The oAuth access token's secret, also retrieved + from the get_access_token.py run. + input_encoding: + The encoding used to encode input strings. [optional] + request_header: + A dictionary of additional HTTP request headers. [optional] + cache: + The cache instance to use. Defaults to DEFAULT_CACHE. + Use None to disable caching. [optional] + shortner: + The shortner instance to use. Defaults to None. + See shorten_url.py for an example shortner. [optional] + base_url: + The base URL to use to contact the Twitter API. + Defaults to https://twitter.com. [optional] + use_gzip_compression: + Set to True to tell enable gzip compression for any call + made to Twitter. Defaults to False. [optional] + ''' + self.SetCache(cache) + self._urllib = urllib2 + self._cache_timeout = Api.DEFAULT_CACHE_TIMEOUT + self._input_encoding = input_encoding + self._use_gzip = use_gzip_compression + self._oauth_consumer = None + + self._InitializeRequestHeaders(request_headers) + self._InitializeUserAgent() + self._InitializeDefaultParameters() + + if base_url is None: + self.base_url = 'https://api.twitter.com/1' + else: + self.base_url = base_url + + if username is not None and (access_token_key is None or + access_token_secret is None): + print >> sys.stderr, 'Twitter now requires an oAuth Access Token for API calls.' + print >> sys.stderr, 'If your using this library from a command line utility, please' + print >> sys.stderr, 'run the the included get_access_token.py tool to generate one.' + + raise TwitterError('Twitter requires oAuth Access Token for all API access') + + self.SetCredentials(username, password, access_token_key, access_token_secret) + + def SetCredentials(self, + username, + password, + access_token_key=None, + access_token_secret=None): + '''Set the username and password for this instance + + Args: + username: + The username of the twitter account. + password: + The password for the twitter account. + access_token_key: + The oAuth access token key value you retrieved + from running get_access_token.py. + access_token_secret: + The oAuth access token's secret, also retrieved + from the get_access_token.py run. + ''' + self._username = username + self._password = password + self._access_token_key = access_token_key + self._access_token_secret = access_token_secret + self._oauth_consumer = None + + if username is not None and password is not None and \ + access_token_key is not None and access_token_secret is not None: + self._signature_method_plaintext = oauth.SignatureMethod_PLAINTEXT() + self._signature_method_hmac_sha1 = oauth.SignatureMethod_HMAC_SHA1() + + self._oauth_token = oauth.Token(key=access_token_key, secret=access_token_secret) + self._oauth_consumer = oauth.Consumer(key=username, secret=password) + + def ClearCredentials(self): + '''Clear the any credentials for this instance + ''' + self._username = None + self._password = None + self._access_token_key = None + self._access_token_secret = None + self._oauth_consumer = None + + def GetPublicTimeline(self, + since_id=None): + '''Fetch the sequence of public twitter.Status message for all users. + + Args: + since_id: + Returns only public statuses with an ID greater than + (that is, more recent than) the specified ID. [optional] + + Returns: + An sequence of twitter.Status instances, one for each message + ''' + parameters = {} + + if since_id: + parameters['since_id'] = since_id + + url = '%s/statuses/public_timeline.json' % self.base_url + json = self._FetchUrl(url, parameters=parameters) + data = simplejson.loads(json) + + self._CheckForTwitterError(data) + + return [Status.NewFromJsonDict(x) for x in data] + + def FilterPublicTimeline(self, + term, + since_id=None): + '''Filter the public twitter timeline by a given search term on + the local machine. + + Args: + term: + term to search by. + since_id: + Returns only public statuses with an ID greater than + (that is, more recent than) the specified ID. [optional] + + Returns: + A sequence of twitter.Status instances, one for each message + containing the term + ''' + statuses = self.GetPublicTimeline(since_id) + results = [] + + for s in statuses: + if s.text.lower().find(term.lower()) != -1: + results.append(s) + + return results + + def GetSearch(self, + term, + geocode=None, + since_id=None, + per_page=15, + page=1, + lang=None, + show_user="true", + query_users=False): + '''Return twitter search results for a given term. + + Args: + term: + term to search by. + since_id: + Returns only public statuses with an ID greater than + (that is, more recent than) the specified ID. [optional] + geocode: + geolocation information in the form (latitude, longitude, radius) + [optional] + per_page: + number of results to return. Default is 15 [optional] + page: + which page of search results to return + lang: + language for results. Default is English [optional] + show_user: + prefixes screen name in status + query_users: + If set to False, then all users only have screen_name and + profile_image_url available. + If set to True, all information of users are available, + but it uses lots of request quota, one per status. + Returns: + A sequence of twitter.Status instances, one for each message containing + the term + ''' + # Build request parameters + parameters = {} + + if since_id: + parameters['since_id'] = since_id + + if (not term) and (not geocode): + return [] + + parameters['q'] = term #urllib.quote_plus(term) + parameters['show_user'] = show_user + if lang: + parameters['lang'] = lang + parameters['rpp'] = per_page + parameters['page'] = page + + if geocode is not None: + parameters['geocode'] = ','.join(map(unicode, geocode)) + + # Make and send requests + if 'twitter' in self.base_url: + url = 'http://search.twitter.com/search.json' + else: + url = '%s/search.json' % self.base_url + + json = self._FetchUrl(url, post_data=parameters) + data = simplejson.loads(json) + + self._CheckForTwitterError(data) + + results = [] + + for x in data['results']: + temp = Status.NewFromJsonDict(x) + + if query_users: + # Build user object with new request + temp.user = self.GetUser(urllib.quote(x['from_user'])) + else: + temp.user = User(screen_name=x['from_user'], profile_image_url=x['profile_image_url']) + + results.append(temp) + + # Return built list of statuses + return results # [Status.NewFromJsonDict(x) for x in data['results']] + + def GetFriendsTimeline(self, + user=None, + count=None, + since=None, + since_id=None, + retweets=False): + '''Fetch the sequence of twitter.Status messages for a user's friends + + The twitter.Api instance must be authenticated if the user is private. + + Args: + user: + Specifies the ID or screen name of the user for whom to return + the friends_timeline. If unspecified, the username and password + must be set in the twitter.Api instance. [Optional] + count: + Specifies the number of statuses to retrieve. May not be + greater than 200. [Optional] + since: + Narrows the returned results to just those statuses created + after the specified HTTP-formatted date. [Optional] + since_id: + Returns only public statuses with an ID greater than (that is, + more recent than) the specified ID. [Optional] + + Returns: + A sequence of twitter.Status instances, one for each message + ''' + if not user and not self._oauth_consumer: + raise TwitterError("User must be specified if API is not authenticated.") + url = '%s/statuses' % self.base_url + if retweets: + src = 'home_timeline' + else: + src = 'friends_timeline' + + if user: + url = '%s/%s/%s.json' % (url, src, user) + else: + url = '%s/%s.json' % (url, src) + parameters = {} + if count is not None: + try: + if int(count) > 200: + raise TwitterError("'count' may not be greater than 200") + except ValueError: + raise TwitterError("'count' must be an integer") + parameters['count'] = count + if since: + parameters['since'] = since + if since_id: + parameters['since_id'] = since_id + + json = self._FetchUrl(url, parameters=parameters) + data = simplejson.loads(json) + self._CheckForTwitterError(data) + return [Status.NewFromJsonDict(x) for x in data] + + + def GetUserTimeline(self, + id=None, + user_id=None, + screen_name=None, + since_id=None, + max_id=None, + count=None, + page=None, + include_rts=None, + include_entities=None): + '''Fetch the sequence of public Status messages for a single user. + + The twitter.Api instance must be authenticated if the user is private. + + Args: + id: + Specifies the ID or screen name of the user for whom to return + the user_timeline. [Optional] + user_id: + Specfies the ID of the user for whom to return the + user_timeline. Helpful for disambiguating when a valid user ID + is also a valid screen name. [Optional] + screen_name: + Specfies the screen name of the user for whom to return the + user_timeline. Helpful for disambiguating when a valid screen + name is also a user ID. [Optional] + since_id: + Returns results with an ID greater than (that is, more recent + than) the specified ID. There are limits to the number of + Tweets which can be accessed through the API. If the limit of + Tweets has occured since the since_id, the since_id will be + forced to the oldest ID available. [Optional] + max_id: + Returns only statuses with an ID less than (that is, older + than) or equal to the specified ID. [Optional] + count: + Specifies the number of statuses to retrieve. May not be + greater than 200. [Optional] + page: + Specifies the page of results to retrieve. + Note: there are pagination limits. [Optional] + include_rts: + If True, the timeline will contain native retweets (if they + exist) in addition to the standard stream of tweets. [Optional] + include_entities: + If True, each tweet will include a node called "entities,". + This node offers a variety of metadata about the tweet in a + discreet structure, including: user_mentions, urls, and + hashtags. [Optional] + + Returns: + A sequence of Status instances, one for each message up to count + ''' + parameters = {} + + if id: + url = '%s/statuses/user_timeline/%s.json' % (self.base_url, id) + elif user_id: + url = '%s/statuses/user_timeline.json?user_id=%d' % (self.base_url, user_id) + elif screen_name: + url = ('%s/statuses/user_timeline.json?screen_name=%s' % (self.base_url, + screen_name)) + elif not self._oauth_consumer: + raise TwitterError("User must be specified if API is not authenticated.") + else: + url = '%s/statuses/user_timeline.json' % self.base_url + + if since_id: + try: + parameters['since_id'] = long(since_id) + except: + raise TwitterError("since_id must be an integer") + + if max_id: + try: + parameters['max_id'] = long(max_id) + except: + raise TwitterError("max_id must be an integer") + + if count: + try: + parameters['count'] = int(count) + except: + raise TwitterError("count must be an integer") + + if page: + try: + parameters['page'] = int(page) + except: + raise TwitterError("page must be an integer") + + if include_rts: + parameters['include_rts'] = 1 + + if include_entities: + parameters['include_entities'] = 1 + + json = self._FetchUrl(url, parameters=parameters) + data = simplejson.loads(json) + self._CheckForTwitterError(data) + return [Status.NewFromJsonDict(x) for x in data] + + + def GetHomeTimeline(self, + since_id=None, + max_id=None, + count=None, + page=None, + include_entities=None): + '''Fetch the sequence of home Status messages for authenticated user. + + The twitter.Api instance must be authenticated if the user is private. + + Args: + since_id: + Returns results with an ID greater than (that is, more recent + than) the specified ID. There are limits to the number of + Tweets which can be accessed through the API. If the limit of + Tweets has occured since the since_id, the since_id will be + forced to the oldest ID available. [Optional] + max_id: + Returns only statuses with an ID less than (that is, older + than) or equal to the specified ID. [Optional] + count: + Specifies the number of statuses to retrieve. May not be + greater than 200. [Optional] + page: + Specifies the page of results to retrieve. + Note: there are pagination limits. [Optional] + include_rts: + If True, the timeline will contain native retweets (if they + exist) in addition to the standard stream of tweets. [Optional] + include_entities: + If True, each tweet will include a node called "entities,". + This node offers a variety of metadata about the tweet in a + discreet structure, including: user_mentions, urls, and + hashtags. [Optional] + + Returns: + A sequence of Status instances, one for each message up to count + ''' + parameters = {} + + url = '%s/statuses/home_timeline.json' % self.base_url + + if since_id: + try: + parameters['since_id'] = long(since_id) + except: + raise TwitterError("since_id must be an integer") + + if max_id: + try: + parameters['max_id'] = long(max_id) + except: + raise TwitterError("max_id must be an integer") + + if count: + try: + parameters['count'] = int(count) + except: + raise TwitterError("count must be an integer") + + if page: + try: + parameters['page'] = int(page) + except: + raise TwitterError("page must be an integer") + + if include_entities: + parameters['include_entities'] = 1 + + json = self._FetchUrl(url, parameters=parameters) + data = simplejson.loads(json) + self._CheckForTwitterError(data) + return [Status.NewFromJsonDict(x) for x in data] + + def GetRetweetsForStatus(self,id=None): + '''Return retweet of a status + The twitter.Api instance must be authenticated if the status message is private. + + Args: + id: The numerical ID of the status to retrieve retweets. + + Returns: + A sequence of Status instances, one for each retweet + ''' + if id == None: + raise TwitterError("A status id must be specified.") + elif not self._oauth_consumer: + raise TwitterError("User must be specified if API is not authenticated.") + + url = '%s/statuses/retweets/%id.json' % (self.base_url,id) + + json = self._FetchUrl(url, parameters={'id':id,}) + data = simplejson.loads(json) + self._CheckForTwitterError(data) + return [Status.NewFromJsonDict(x) for x in data] + + def GetStatus(self, id): + '''Returns a single status message. + + The twitter.Api instance must be authenticated if the status message is private. + + Args: + id: The numerical ID of the status you're trying to retrieve. + + Returns: + A twitter.Status instance representing that status message + ''' + try: + if id: + long(id) + except: + raise TwitterError("id must be an long integer") + url = '%s/statuses/show/%s.json' % (self.base_url, id) + json = self._FetchUrl(url) + data = simplejson.loads(json) + self._CheckForTwitterError(data) + return Status.NewFromJsonDict(data) + + def DestroyStatus(self, id): + '''Destroys the status specified by the required ID parameter. + + The twitter.Api instance must be authenticated and thee + authenticating user must be the author of the specified status. + + Args: + id: The numerical ID of the status you're trying to destroy. + + Returns: + A twitter.Status instance representing the destroyed status message + ''' + try: + if id: + long(id) + except: + raise TwitterError("id must be an integer") + url = '%s/statuses/destroy/%s.json' % (self.base_url, id) + pdata = {} + pdata['id']=id + json = self._FetchUrl(url, post_data=pdata) + data = simplejson.loads(json) + self._CheckForTwitterError(data) + return Status.NewFromJsonDict(data) + + def PostUpdate(self, status, in_reply_to_status_id=None, latitude=None, longitude=None): + '''Post a twitter status message from the authenticated user. + + The twitter.Api instance must be authenticated. + + Args: + status: + The message text to be posted. Must be less than or equal to + 140 characters. + in_reply_to_status_id: + The ID of an existing status that the status to be posted is + in reply to. This implicitly sets the in_reply_to_user_id + attribute of the resulting status to the user ID of the + message being replied to. Invalid/missing status IDs will be + ignored. [Optional] + Returns: + A twitter.Status instance representing the message posted. + ''' + if not self._oauth_consumer: + raise TwitterError("The twitter.Api instance must be authenticated.") + + url = '%s/statuses/update.json' % self.base_url + + #тест тест тест тест тест тест тест тест тест тест тест тест тест тест тест тест тест тест тест тест тест тест тест тест тест тест тест тест + if len(status.decode('utf-8')) > CHARACTER_LIMIT: + raise TwitterError("Text must be less than or equal to %d characters. " + "Consider using PostUpdates." % CHARACTER_LIMIT) + + data = {'status': status} + if in_reply_to_status_id: + data['in_reply_to_status_id'] = in_reply_to_status_id + if latitude is not None: + data['lat'] = latitude + data['display_coordinates'] = True + data['geo_enabled'] = True + if longitude is not None: + data['long'] = longitude + +# print data + json = self._FetchUrl(url, post_data=data) + data = simplejson.loads(json) + self._CheckForTwitterError(data) + return Status.NewFromJsonDict(data) + + def PostSerializedUpdates(self, status, continuation=None, **kwargs): + '''Post one or more twitter status messages from the authenticated user. + + Unlike api.PostUpdate, this method will post multiple status updates + if the message is longer than 140 characters. + + The twitter.Api instance must be authenticated. + + Args: + status: + The message text to be posted. May be longer than 140 characters. + continuation: + The character string, if any, to be appended to all but the + last message. Note that Twitter strips trailing '...' strings + from messages. Consider using the unicode \u2026 character + (horizontal ellipsis) instead. [Defaults to None] + **kwargs: + See api.PostUpdate for a list of accepted parameters. + Returns: + A of list twitter.Status instance representing the messages posted. + ''' + results = list() + line_length = CHARACTER_LIMIT + lines = textwrap.wrap(status, line_length) + if len(lines) > 9: + line_length = CHARACTER_LIMIT - 6 + lines = textwrap.wrap(status, line_length) + elif len(lines) > 1: + line_length = CHARACTER_LIMIT - 4 + lines = textwrap.wrap(status, line_length) + counter = 1 + tot = len(lines) + print kwargs + if len(lines)==1: + results.append(self.PostUpdate(lines[0], **kwargs)) + else: + for line in lines: + #print 'line',type(unicode(line,'utf-8').encode('utf-8')),line + r = self.PostUpdate(line \ + + ' ' \ + + str(counter) \ + + '/' \ + + str(tot), \ + **kwargs) + results.append(r) + counter = counter + 1 + return results + + def PostUpdates(self, status, continuation=None, **kwargs): + '''Post one or more twitter status messages from the authenticated user. + + Unlike api.PostUpdate, this method will post multiple status updates + if the message is longer than 140 characters. + + The twitter.Api instance must be authenticated. + + Args: + status: + The message text to be posted. May be longer than 140 characters. + continuation: + The character string, if any, to be appended to all but the + last message. Note that Twitter strips trailing '...' strings + from messages. Consider using the unicode \u2026 character + (horizontal ellipsis) instead. [Defaults to None] + **kwargs: + See api.PostUpdate for a list of accepted parameters. + Returns: + A of list twitter.Status instance representing the messages posted. + ''' + results = list() + if continuation is None: + continuation = '' + line_length = CHARACTER_LIMIT - len(continuation) + lines = textwrap.wrap(status, line_length) + for line in lines[0:-1]: + results.append(self.PostUpdate(line + continuation, **kwargs)) + results.append(self.PostUpdate(lines[-1], **kwargs)) + return results + + def PostRetweet(self, tweet_id): + '''Retweet a tweet with the Retweet API + + The twitter.Api instance must be authenticated. + + Args: + id: The numerical ID of the tweet you are retweeting + + Returns: + A twitter.Status instance representing the retweet posted + ''' + if not self._username: + raise TwitterError("The twitter.Api instance must be authenticated.") + try: + if long(tweet_id) <= 0: + raise TwitterError("'id' must be a positive number") + except ValueError: + raise TwitterError("'id' must be an integer") + + data = {'id': tweet_id} + url = '%s/statuses/retweet/%s.json' % (self.base_url,tweet_id) +# url = 'http://api.twitter.com/1/statuses/retweet/%s.json' % tweet_id + json = self._FetchUrl(url, post_data=data) + data = simplejson.loads(json) + self._CheckForTwitterError(data) + return Status.NewFromJsonDict(data) + + def GetReplies(self, since=None, since_id=None, page=None): + '''Get a sequence of status messages representing the 20 most recent + replies (status updates prefixed with @username) to the authenticating + user. + + Args: + page: + since: + Narrows the returned results to just those statuses created + after the specified HTTP-formatted date. [optional] + since_id: + Returns only public statuses with an ID greater than (that is, + more recent than) the specified ID. [Optional] + + Returns: + A sequence of twitter.Status instances, one for each reply to the user. + ''' + url = '%s/statuses/replies.json' % self.base_url + if not self._oauth_consumer: + raise TwitterError("The twitter.Api instance must be authenticated.") + parameters = {} + if since: + parameters['since'] = since + if since_id: + parameters['since_id'] = since_id + if page: + parameters['page'] = page + json = self._FetchUrl(url, parameters=parameters) + data = simplejson.loads(json) + self._CheckForTwitterError(data) + return [Status.NewFromJsonDict(x) for x in data] + + def GetFriends(self, user=None, cursor=-1): + '''Fetch the sequence of twitter.User instances, one for each friend. + + Args: + user: the username or id of the user whose friends you are fetching. If + not specified, defaults to the authenticated user. [optional] + + The twitter.Api instance must be authenticated. + + Returns: + A sequence of twitter.User instances, one for each friend + ''' + if not user and not self._oauth_consumer: + raise TwitterError("twitter.Api instance must be authenticated") + if user: + url = '%s/statuses/friends/%s.json' % (self.base_url, user) + else: + url = '%s/statuses/friends.json' % self.base_url + parameters = {} + parameters['cursor'] = cursor + json = self._FetchUrl(url, parameters=parameters) + data = simplejson.loads(json) + self._CheckForTwitterError(data) + return [User.NewFromJsonDict(x) for x in data] + + def GetSubscriptions(self, user, cursor=-1): + '''Fetch the sequence of Lists that the given user is subscribed to + + The twitter.Api instance must be authenticated. + + Args: + user: + The twitter name or id of the user + cursor: + "page" value that Twitter will use to start building the + list sequence from. -1 to start at the beginning. + Twitter will return in the result the values for next_cursor + and previous_cursor. [Optional] + + Returns: + A sequence of twitter.List instances, one for each list + ''' + if not self._oauth_consumer: + raise TwitterError("twitter.Api instance must be authenticated") + + url = '%s/%s/lists/subscriptions.json' % (self.base_url, user) + parameters = {} + parameters['cursor'] = cursor + + json = self._FetchUrl(url, parameters=parameters) + data = simplejson.loads(json) + self._CheckForTwitterError(data) + print data + return [List.NewFromJsonDict(x) for x in data['lists']] + + def CreateList(self, user, name, mode=None, description=None): + '''Creates a new list with the give name + + The twitter.Api instance must be authenticated. + + Args: + user: + Twitter name to create the list for + name: + New name for the list + mode: + 'public' or 'private'. + Defaults to 'public'. [Optional] + description: + Description of the list. [Optional] + + Returns: + A twitter.List instance representing the new list + ''' + url = '%s/%s/lists.json' % (self.base_url, user) + parameters = {'name': name} + if mode is not None: + parameters['mode'] = mode + if description is not None: + parameters['description'] = description + json = self._FetchUrl(url, post_data=parameters) + data = simplejson.loads(json) + self._CheckForTwitterError(data) + return List.NewFromJsonDict(data) + + def DestroyList(self, user, id): + '''Destroys the list from the given user + + The twitter.Api instance must be authenticated. + + Args: + user: + The user to remove the list from. + id: + The slug or id of the list to remove. + Returns: + A twitter.List instance representing the removed list. + ''' + url = '%s/%s/lists/%s.json' % (self.base_url, user, id) + json = self._FetchUrl(url, post_data={'_method': 'DELETE'}) + data = simplejson.loads(json) + self._CheckForTwitterError(data) + return List.NewFromJsonDict(data) + + def CreateSubscription(self, owner, list): + '''Creates a subscription to a list by the authenticated user + + The twitter.Api instance must be authenticated. + + Args: + owner: + User name or id of the owner of the list being subscribed to. + list: + The slug or list id to subscribe the user to + + Returns: + A twitter.List instance representing the list subscribed to + ''' + url = '%s/%s/%s/subscribers.json' % (self.base_url, owner, list) + json = self._FetchUrl(url, post_data={'list_id': list}) + data = simplejson.loads(json) + self._CheckForTwitterError(data) + return List.NewFromJsonDict(data) + + def DestroySubscription(self, owner, list): + '''Destroys the subscription to a list for the authenticated user + + The twitter.Api instance must be authenticated. + + Args: + owner: + The user id or screen name of the user that owns the + list that is to be unsubscribed from + list: + The slug or list id of the list to unsubscribe from + + Returns: + A twitter.List instance representing the removed list. + ''' + url = '%s/%s/%s/subscribers.json' % (self.base_url, owner, list) + json = self._FetchUrl(url, post_data={'_method': 'DELETE', 'list_id': list}) + data = simplejson.loads(json) + self._CheckForTwitterError(data) + return List.NewFromJsonDict(data) + + def GetLists(self, user, cursor=-1): + '''Fetch the sequence of lists for a user. + + The twitter.Api instance must be authenticated. + + Args: + user: + The twitter name or id of the user whose friends you are fetching. + If the passed in user is the same as the authenticated user + then you will also receive private list data. + cursor: + "page" value that Twitter will use to start building the + list sequence from. -1 to start at the beginning. + Twitter will return in the result the values for next_cursor + and previous_cursor. [Optional] + + Returns: + A sequence of twitter.List instances, one for each list + ''' + if not self._oauth_consumer: + raise TwitterError("twitter.Api instance must be authenticated") + + url = '%s/%s/lists.json' % (self.base_url, user) + parameters = {} + parameters['cursor'] = cursor + + json = self._FetchUrl(url, parameters=parameters) + data = simplejson.loads(json) + self._CheckForTwitterError(data) + return [List.NewFromJsonDict(x) for x in data['lists']] + + def GetFriendIDs(self, user=None, cursor=-1): + '''Returns a list of twitter user id's for every person + the specified user is following. + + Args: + user: + The id or screen_name of the user to retrieve the id list for + [optional] + + Returns: + A list of integers, one for each user id. + ''' + if not user and not self._oauth_consumer: + raise TwitterError("twitter.Api instance must be authenticated") + if user: + url = '%s/friends/ids/%s.json' % (self.base_url, user) + else: + url = '%s/friends/ids.json' % self.base_url + parameters = {} + parameters['cursor'] = cursor + json = self._FetchUrl(url, parameters=parameters) + data = simplejson.loads(json) + self._CheckForTwitterError(data) + return data + + def GetFollowerIDs(self, userid=None, cursor=-1): + '''Fetch the sequence of twitter.User instances, one for each follower + + The twitter.Api instance must be authenticated. + + Returns: + A sequence of twitter.User instances, one for each follower + ''' + url = 'http://twitter.com/followers/ids.json' + parameters = {} + parameters['cursor'] = cursor + if userid: + parameters['user_id'] = userid + json = self._FetchUrl(url, parameters=parameters) + data = simplejson.loads(json) + self._CheckForTwitterError(data) + return data + + def GetFollowers(self, page=None): + '''Fetch the sequence of twitter.User instances, one for each follower + + The twitter.Api instance must be authenticated. + + Returns: + A sequence of twitter.User instances, one for each follower + ''' + if not self._oauth_consumer: + raise TwitterError("twitter.Api instance must be authenticated") + url = '%s/statuses/followers.json' % self.base_url + parameters = {} + if page: + parameters['page'] = page + json = self._FetchUrl(url, parameters=parameters) + data = simplejson.loads(json) + self._CheckForTwitterError(data) + return [User.NewFromJsonDict(x) for x in data] + + def GetFeatured(self): + '''Fetch the sequence of twitter.User instances featured on twitter.com + + The twitter.Api instance must be authenticated. + + Returns: + A sequence of twitter.User instances + ''' + url = '%s/statuses/featured.json' % self.base_url + json = self._FetchUrl(url) + data = simplejson.loads(json) + self._CheckForTwitterError(data) + return [User.NewFromJsonDict(x) for x in data] + + def GetUser(self, user): + '''Returns a single user. + + The twitter.Api instance must be authenticated. + + Args: + user: The username or id of the user to retrieve. + + Returns: + A twitter.User instance representing that user + ''' + url = '%s/users/show/%s.json' % (self.base_url, user) + json = self._FetchUrl(url) + data = simplejson.loads(json) + self._CheckForTwitterError(data) + return User.NewFromJsonDict(data) + + def GetDirectMessages(self, since=None, since_id=None, page=None): + '''Returns a list of the direct messages sent to the authenticating user. + + The twitter.Api instance must be authenticated. + + Args: + since: + Narrows the returned results to just those statuses created + after the specified HTTP-formatted date. [optional] + since_id: + Returns only public statuses with an ID greater than (that is, + more recent than) the specified ID. [Optional] + + Returns: + A sequence of twitter.DirectMessage instances + ''' + url = '%s/direct_messages.json' % self.base_url + if not self._oauth_consumer: + raise TwitterError("The twitter.Api instance must be authenticated.") + parameters = {} + if since: + parameters['since'] = since + if since_id: + parameters['since_id'] = since_id + if page: + parameters['page'] = page + json = self._FetchUrl(url, parameters=parameters) + data = simplejson.loads(json) + self._CheckForTwitterError(data) + return [DirectMessage.NewFromJsonDict(x) for x in data] + + def PostDirectMessage(self, user, text): + '''Post a twitter direct message from the authenticated user + + The twitter.Api instance must be authenticated. + + Args: + user: The ID or screen name of the recipient user. + text: The message text to be posted. Must be less than 140 characters. + + Returns: + A twitter.DirectMessage instance representing the message posted + ''' + if not self._oauth_consumer: + raise TwitterError("The twitter.Api instance must be authenticated.") + url = '%s/direct_messages/new.json' % self.base_url + data = {'text': text, 'user': user} + json = self._FetchUrl(url, post_data=data) + data = simplejson.loads(json) + self._CheckForTwitterError(data) + return DirectMessage.NewFromJsonDict(data) + + def DestroyDirectMessage(self, id): + '''Destroys the direct message specified in the required ID parameter. + + The twitter.Api instance must be authenticated, and the + authenticating user must be the recipient of the specified direct + message. + + Args: + id: The id of the direct message to be destroyed + + Returns: + A twitter.DirectMessage instance representing the message destroyed + ''' + url = '%s/direct_messages/destroy/%s.json' % (self.base_url, id) + json = self._FetchUrl(url, post_data={}) + data = simplejson.loads(json) + self._CheckForTwitterError(data) + return DirectMessage.NewFromJsonDict(data) + + def CreateFriendship(self, user): + '''Befriends the user specified in the user parameter as the authenticating user. + + The twitter.Api instance must be authenticated. + + Args: + The ID or screen name of the user to befriend. + Returns: + A twitter.User instance representing the befriended user. + ''' + url = '%s/friendships/create/%s.json' % (self.base_url, user) + json = self._FetchUrl(url, post_data={'user':user}) + data = simplejson.loads(json) + self._CheckForTwitterError(data) + return User.NewFromJsonDict(data) + + def DestroyFriendship(self, user): + '''Discontinues friendship with the user specified in the user parameter. + + The twitter.Api instance must be authenticated. + + Args: + The ID or screen name of the user with whom to discontinue friendship. + Returns: + A twitter.User instance representing the discontinued friend. + ''' + url = '%s/friendships/destroy/%s.json' % (self.base_url, user) + json = self._FetchUrl(url, post_data={'user':user}) + data = simplejson.loads(json) + self._CheckForTwitterError(data) + return User.NewFromJsonDict(data) + + def CreateFavorite(self, status_id): + '''Favorites the status specified in the status parameter as the authenticating user. + Returns the favorite status when successful. + + The twitter.Api instance must be authenticated. + + Args: + The twitter.Status instance to mark as a favorite. + Returns: + A twitter.Status instance representing the newly-marked favorite. + ''' + url = '%s/favorites/create/%s.json' % (self.base_url, status_id) + json = self._FetchUrl(url, post_data={'id':status_id}) + data = simplejson.loads(json) + self._CheckForTwitterError(data) + return Status.NewFromJsonDict(data) + + def DestroyFavorite(self, status_id): + '''Un-favorites the status specified in the ID parameter as the authenticating user. + Returns the un-favorited status in the requested format when successful. + + The twitter.Api instance must be authenticated. + + Args: + The twitter.Status to unmark as a favorite. + Returns: + A twitter.Status instance representing the newly-unmarked favorite. + ''' + url = '%s/favorites/destroy/%s.json' % (self.base_url, status_id) + json = self._FetchUrl(url, post_data={'id':status_id}) + data = simplejson.loads(json) + self._CheckForTwitterError(data) + return Status.NewFromJsonDict(data) + + def GetFavorites(self, + user=None, + page=None): + '''Return a list of Status objects representing favorited tweets. + By default, returns the (up to) 20 most recent tweets for the + authenticated user. + + Args: + user: + The username or id of the user whose favorites you are fetching. + If not specified, defaults to the authenticated user. [optional] + + page: + Retrieves the 20 next most recent favorite statuses. [optional] + ''' + parameters = {} + + if page: + parameters['page'] = page + + if user: + url = '%s/favorites/%s.json' % (self.base_url, user) + elif not user and not self._oauth_consumer: + raise TwitterError("User must be specified if API is not authenticated.") + else: + url = '%s/favorites.json' % self.base_url + + json = self._FetchUrl(url, parameters=parameters) + data = simplejson.loads(json) + + self._CheckForTwitterError(data) + + return [Status.NewFromJsonDict(x) for x in data] + + def GetMentions(self, + since_id=None, + max_id=None, + page=None, + include_rts=None): + '''Returns the 20 most recent mentions (status containing @username) + for the authenticating user. + + Args: + since_id: + Returns only public statuses with an ID greater than + (that is, more recent than) the specified ID. [optional] + + max_id: + Returns only statuses with an ID less than + (that is, older than) the specified ID. [optional] + + page: + Retrieves the 20 next most recent replies. [optional] + + Returns: + A sequence of twitter.Status instances, one for each mention of the user. + see: http://apiwiki.twitter.com/REST-API-Documentation#statuses/mentions + ''' + + url = '%s/statuses/mentions.json' % self.base_url + + if not self._oauth_consumer: + raise TwitterError("The twitter.Api instance must be authenticated.") + + parameters = {} + + if since_id: + parameters['since_id'] = since_id + if max_id: + parameters['max_id'] = max_id + if page: + parameters['page'] = page + if include_rts: + parameters['include_rts'] = 1 + + json = self._FetchUrl(url, parameters=parameters) + #print json + data = simplejson.loads(json) + + self._CheckForTwitterError(data) + + return [Status.NewFromJsonDict(x) for x in data] + + def GetRetweetedByMe(self, + since_id=None, + max_id=None, + page=None): + '''Returns the 20 most recent retweet made by user (status containing @username) + for the authenticating user. + + Args: + since_id: + Returns only public statuses with an ID greater than + (that is, more recent than) the specified ID. [optional] + + max_id: + Returns only statuses with an ID less than + (that is, older than) the specified ID. [optional] + + page: + Retrieves the 20 next most recent replies. [optional] + + Returns: + A sequence of twitter.Status instances, one for each retweet of the user. + see: http://apiwiki.twitter.com/REST-API-Documentation#statuses/mentions + ''' + + url = '%s/statuses/retweeted_by_me.json' % self.base_url + + if not self._oauth_consumer: + raise TwitterError("The twitter.Api instance must be authenticated.") + + parameters = {} + + if since_id: + parameters['since_id'] = since_id + if max_id: + parameters['max_id'] = max_id + if page: + parameters['page'] = page + + json = self._FetchUrl(url, parameters=parameters) + data = simplejson.loads(json) + + self._CheckForTwitterError(data) + + return [Status.NewFromJsonDict(x) for x in data] + + def GetRetweetsOfMe(self, + since_id=None, + max_id=None, + page=None): + '''Returns the 20 most recent retweet of tweet made by the user (status containing @username) + for the authenticating user. + + Args: + since_id: + Returns only public statuses with an ID greater than + (that is, more recent than) the specified ID. [optional] + + max_id: + Returns only statuses with an ID less than + (that is, older than) the specified ID. [optional] + + page: + Retrieves the 20 next most recent replies. [optional] + + Returns: + A sequence of twitter.Status instances, one for each retweet of the user. + see: http://apiwiki.twitter.com/REST-API-Documentation#statuses/mentions + ''' + + url = '%s/statuses/retweets_of_me.json' % self.base_url + + if not self._oauth_consumer: + raise TwitterError("The twitter.Api instance must be authenticated.") + + parameters = {} + + if since_id: + parameters['since_id'] = since_id + if max_id: + parameters['max_id'] = max_id + if page: + parameters['page'] = page + parameters['trim_user'] = 'false' + + json = self._FetchUrl(url, parameters=parameters) + data = simplejson.loads(json) + + self._CheckForTwitterError(data) + + return [Status.NewFromJsonDict(x) for x in data] + + def GetRetweetedToMe(self, + since_id=None, + max_id=None, + page=None): + '''Returns the 20 most recent retweet to the user (status containing @username) + for the authenticating user. + + Args: + since_id: + Returns only public statuses with an ID greater than + (that is, more recent than) the specified ID. [optional] + + max_id: + Returns only statuses with an ID less than + (that is, older than) the specified ID. [optional] + + page: + Retrieves the 20 next most recent replies. [optional] + + Returns: + A sequence of twitter.Status instances, one for each retweet of the user. + see: http://apiwiki.twitter.com/REST-API-Documentation#statuses/mentions + ''' + + url = '%s/statuses/retweeted_to_me.json' % self.base_url + + if not self._oauth_consumer: + raise TwitterError("The twitter.Api instance must be authenticated.") + + parameters = {} + + if since_id: + parameters['since_id'] = since_id + if max_id: + parameters['max_id'] = max_id + if page: + parameters['page'] = page + + json = self._FetchUrl(url, parameters=parameters) + data = simplejson.loads(json) + + self._CheckForTwitterError(data) + + return [Status.NewFromJsonDict(x) for x in data] + + def GetUserByEmail(self, email): + '''Returns a single user by email address. + + Args: + email: The email of the user to retrieve. + Returns: + A twitter.User instance representing that user + ''' + url = '%s/users/show.json?email=%s' % (self.base_url, email) + json = self._FetchUrl(url) + data = simplejson.loads(json) + self._CheckForTwitterError(data) + return User.NewFromJsonDict(data) + + def VerifyCredentials(self): + '''Returns a twitter.User instance if the authenticating user is valid. + + Returns: + A twitter.User instance representing that user if the + credentials are valid, None otherwise. + ''' + if not self._oauth_consumer: + raise TwitterError("Api instance must first be given user credentials.") + url = '%s/account/verify_credentials.json' % self.base_url + try: + json = self._FetchUrl(url, no_cache=True) + except urllib2.HTTPError, http_error: + if http_error.code == httplib.UNAUTHORIZED: + return None + else: + raise http_error + data = simplejson.loads(json) + self._CheckForTwitterError(data) + return User.NewFromJsonDict(data) + + def SetCache(self, cache): + '''Override the default cache. Set to None to prevent caching. + + Args: + cache: an instance that supports the same API as the twitter._FileCache + ''' + if cache == DEFAULT_CACHE: + self._cache = _FileCache() + else: + self._cache = cache + + def SetUrllib(self, urllib): + '''Override the default urllib implementation. + + Args: + urllib: an instance that supports the same API as the urllib2 module + ''' + self._urllib = urllib + + def SetCacheTimeout(self, cache_timeout): + '''Override the default cache timeout. + + Args: + cache_timeout: time, in seconds, that responses should be reused. + ''' + self._cache_timeout = cache_timeout + + def SetUserAgent(self, user_agent): + '''Override the default user agent + + Args: + user_agent: a string that should be send to the server as the User-agent + ''' + self._request_headers['User-Agent'] = user_agent + + def SetXTwitterHeaders(self, client, url, version): + '''Set the X-Twitter HTTP headers that will be sent to the server. + + Args: + client: + The client name as a string. Will be sent to the server as + the 'X-Twitter-Client' header. + url: + The URL of the meta.xml as a string. Will be sent to the server + as the 'X-Twitter-Client-URL' header. + version: + The client version as a string. Will be sent to the server + as the 'X-Twitter-Client-Version' header. + ''' + self._request_headers['X-Twitter-Client'] = client + self._request_headers['X-Twitter-Client-URL'] = url + self._request_headers['X-Twitter-Client-Version'] = version + + def SetSource(self, source): + '''Suggest the "from source" value to be displayed on the Twitter web site. + + The value of the 'source' parameter must be first recognized by + the Twitter server. New source values are authorized on a case by + case basis by the Twitter development team. + + Args: + source: + The source name as a string. Will be sent to the server as + the 'source' parameter. + ''' + self._default_params['source'] = source + + def GetRateLimitStatus(self): + '''Fetch the rate limit status for the currently authorized user. + + Returns: + A dictionary containing the time the limit will reset (reset_time), + the number of remaining hits allowed before the reset (remaining_hits), + the number of hits allowed in a 60-minute period (hourly_limit), and the + time of the reset in seconds since The Epoch (reset_time_in_seconds). + ''' + url = '%s/account/rate_limit_status.json' % self.base_url + json = self._FetchUrl(url, no_cache=True) + data = simplejson.loads(json) + + self._CheckForTwitterError(data) + + return data + + def MaximumHitFrequency(self): + '''Determines the minimum number of seconds that a program must wait before + hitting the server again without exceeding the rate_limit imposed for the + currently authenticated user. + + Returns: + The minimum second interval that a program must use so as to not exceed + the rate_limit imposed for the user. + ''' + rate_status = self.GetRateLimitStatus() + reset_time = rate_status.get('reset_time', None) + limit = rate_status.get('remaining_hits', None) + + if reset_time and limit: + # put the reset time into a datetime object + reset = datetime.datetime(*rfc822.parsedate(reset_time)[:7]) + + # find the difference in time between now and the reset time + 1 hour + delta = reset + datetime.timedelta(hours=1) - datetime.datetime.utcnow() + + # determine the minimum number of seconds allowed as a regular interval + max_frequency = int(delta.seconds / limit) + + # return the number of seconds + return max_frequency + + return 0 + + def _BuildUrl(self, url, path_elements=None, extra_params=None): + # Break url into consituent parts + (scheme, netloc, path, params, query, fragment) = urlparse.urlparse(url) + + # Add any additional path elements to the path + if path_elements: + # Filter out the path elements that have a value of None + p = [i for i in path_elements if i] + if not path.endswith('/'): + path += '/' + path += '/'.join(p) + + # Add any additional query parameters to the query string + if extra_params and len(extra_params) > 0: + extra_query = self._EncodeParameters(extra_params) + # Add it to the existing query + if query: + query += '&' + extra_query + else: + query = extra_query + + # Return the rebuilt URL + return urlparse.urlunparse((scheme, netloc, path, params, query, fragment)) + + def _InitializeRequestHeaders(self, request_headers): + if request_headers: + self._request_headers = request_headers + else: + self._request_headers = {} + + def _InitializeUserAgent(self): + user_agent = 'Khweeteur-python-twitter/%s)' % \ + ( __version__) + self.SetUserAgent(user_agent) + + def _InitializeDefaultParameters(self): + self._default_params = {} + + def _DecompressGzippedResponse(self, response): + raw_data = response.read() + if response.headers.get('content-encoding', None) == 'gzip': + url_data = gzip.GzipFile(fileobj=StringIO.StringIO(raw_data)).read() + else: + url_data = raw_data + return url_data + + def _Encode(self, s): + if self._input_encoding: + return unicode(s, self._input_encoding).encode('utf-8') + else: + return unicode(s).encode('utf-8') + + def _EncodeParameters(self, parameters): + '''Return a string in key=value&key=value form + + Values of None are not included in the output string. + + Args: + parameters: + A dict of (key, value) tuples, where value is encoded as + specified by self._encoding + Returns: + A URL-encoded string in "key=value&key=value" form + ''' + if parameters is None: + return None + else: + return urllib.urlencode(dict([(k, self._Encode(v)) for k, v in parameters.items() if v is not None])) + + def _EncodePostData(self, post_data): + '''Return a string in key=value&key=value form + + Values are assumed to be encoded in the format specified by self._encoding, + and are subsequently URL encoded. + + Args: + post_data: + A dict of (key, value) tuples, where value is encoded as + specified by self._encoding + Returns: + A URL-encoded string in "key=value&key=value" form + ''' + if post_data is None: + return None + else: + return urllib.urlencode(dict([(k, self._Encode(v)) for k, v in post_data.items()])) + + def _CheckForTwitterError(self, data): + """Raises a TwitterError if twitter returns an error message. + + Args: + data: A python dict created from the Twitter json response + Raises: + TwitterError wrapping the twitter error message if one exists. + """ + # Twitter errors are relatively unlikely, so it is faster + # to check first, rather than try and catch the exception + if 'error' in data: + raise TwitterError(data['error']) + + def _FetchUrl(self, + url, + post_data=None, + parameters=None, + no_cache=None, + use_gzip_compression=None): + '''Fetch a URL, optionally caching for a specified time. + + Args: + url: + The URL to retrieve + post_data: + A dict of (str, unicode) key/value pairs. + If set, POST will be used. + parameters: + A dict whose key/value pairs should encoded and added + to the query string. [optional] + no_cache: + If true, overrides the cache on the current request + use_gzip_compression: + If True, tells the server to gzip-compress the response. + It does not apply to POST requests. + Defaults to None, which will get the value to use from + the instance variable self._use_gzip [optional] + + Returns: + A string containing the body of the response. + ''' + # Build the extra parameters dict + extra_params = {} + if self._default_params: + extra_params.update(self._default_params) + if parameters: + extra_params.update(parameters) + + if post_data: + http_method = "POST" + else: + http_method = "GET" + + http_handler = self._urllib.HTTPHandler(debuglevel=0) + https_handler = self._urllib.HTTPSHandler(debuglevel=0) + + opener = self._urllib.OpenerDirector() + opener.add_handler(http_handler) + opener.add_handler(https_handler) + + if use_gzip_compression is None: + use_gzip = self._use_gzip + else: + use_gzip = use_gzip_compression + + # Set up compression + if use_gzip and not post_data: + opener.addheaders.append(('Accept-Encoding', 'gzip')) + + if self._oauth_consumer is not None: + if post_data and http_method == "POST": + parameters = post_data.copy() + + req = oauth.Request.from_consumer_and_token(self._oauth_consumer, + token=self._oauth_token, + http_method=http_method, + http_url=url, parameters=parameters) + + req.sign_request(self._signature_method_hmac_sha1, self._oauth_consumer, self._oauth_token) + + headers = req.to_header() + + if http_method == "POST": + encoded_post_data = req.to_postdata() + else: + encoded_post_data = None + url = req.to_url() + else: + url = self._BuildUrl(url, extra_params=extra_params) + encoded_post_data = self._EncodePostData(post_data) + + # Open and return the URL immediately if we're not going to cache + if encoded_post_data or no_cache or not self._cache or not self._cache_timeout: + response = opener.open(url, encoded_post_data) + url_data = self._DecompressGzippedResponse(response) + opener.close() + else: + # Unique keys are a combination of the url and the oAuth Consumer Key + if self._username: + key = self._username + ':' + url + else: + key = url + + # See if it has been cached before + last_cached = self._cache.GetCachedTime(key) + + # If the cached version is outdated then fetch another and store it + if not last_cached or time.time() >= last_cached + self._cache_timeout: + try: + response = opener.open(url, encoded_post_data) + url_data = self._DecompressGzippedResponse(response) + except urllib2.HTTPError, e: + print 'Errors ',e + opener.close() + self._cache.Set(key, url_data) + else: + url_data = self._cache.Get(key) + + # Always return the latest version + return url_data + +class _FileCacheError(Exception): + '''Base exception class for FileCache related errors''' + +class _FileCache(object): + + DEPTH = 3 + + def __init__(self,root_directory=None): + self._InitializeRootDirectory(root_directory) + + def Get(self,key): + path = self._GetPath(key) + if os.path.exists(path): + return open(path).read() + else: + return None + + def Set(self,key,data): + path = self._GetPath(key) + directory = os.path.dirname(path) + if not os.path.exists(directory): + os.makedirs(directory) + if not os.path.isdir(directory): + raise _FileCacheError('%s exists but is not a directory' % directory) + temp_fd, temp_path = tempfile.mkstemp() + temp_fp = os.fdopen(temp_fd, 'w') + temp_fp.write(data) + temp_fp.close() + if not path.startswith(self._root_directory): + raise _FileCacheError('%s does not appear to live under %s' % + (path, self._root_directory)) + if os.path.exists(path): + os.remove(path) + os.rename(temp_path, path) + + def Remove(self,key): + path = self._GetPath(key) + if not path.startswith(self._root_directory): + raise _FileCacheError('%s does not appear to live under %s' % + (path, self._root_directory )) + if os.path.exists(path): + os.remove(path) + + def GetCachedTime(self,key): + path = self._GetPath(key) + if os.path.exists(path): + return os.path.getmtime(path) + else: + return None + + def _GetUsername(self): + '''Attempt to find the username in a cross-platform fashion.''' + try: + return os.getenv('USER') or \ + os.getenv('LOGNAME') or \ + os.getenv('USERNAME') or \ + os.getlogin() or \ + 'nobody' + except (IOError, OSError), e: + return 'nobody' + + def _GetTmpCachePath(self): + username = self._GetUsername() + cache_directory = 'python.cache_' + username + return os.path.join(tempfile.gettempdir(), cache_directory) + + def _InitializeRootDirectory(self, root_directory): + if not root_directory: + root_directory = self._GetTmpCachePath() + root_directory = os.path.abspath(root_directory) + if not os.path.exists(root_directory): + os.mkdir(root_directory) + if not os.path.isdir(root_directory): + raise _FileCacheError('%s exists but is not a directory' % + root_directory) + self._root_directory = root_directory + + def _GetPath(self,key): + try: + hashed_key = md5(key).hexdigest() + except TypeError: + hashed_key = md5.new(key).hexdigest() + + return os.path.join(self._root_directory, + self._GetPrefix(hashed_key), + hashed_key) + + def _GetPrefix(self,hashed_key): + return os.path.sep.join(hashed_key[0:_FileCache.DEPTH]) + +if __name__ == '__main__': + print 'test' + s=(u'''тест тест тест тест тест тест тест тест тест тест тест тест тест тест тест тест тест тест тест тест тест тест тест тест тест тест тест тест тест тест ''').encode('utf-8') + s2=(u'''тест тест тест тест тест тест''').encode('utf-8') + api = Api(username='twitterfdghjuser', password='twittgfjker pass', + access_token_key='the_key_g', + access_token_secret='the_key_secret') + api.PostSerializedUpdates(s) diff --git a/khweeteur_32.png b/khweeteur_32.png new file mode 100644 index 0000000000000000000000000000000000000000..5b5c9b9aeba54bd483eb6c9d234c2382f2c9cd79 GIT binary patch literal 1137 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 << EOF +### BEGIN Added by Khweeteur postinst ### +[khweeteur-new-tweets] +Destination=Khweeteur +Icon=khweeteur +Title-Text-Empty=Khweeteur +Secondary-Text=New tweets available +Text-Domain=khweeteur +LED-Pattern=PatternCommonNotification +### END Added by khweeteur postinst ### +EOF + echo "done." +fi +""", + 'prere':"""#!/bin/sh +rm -rf /usr/lib/python2.5/site-packages/khweeteur/*.pyc""", + 'copyright':'gpl'}, + 'bdist_rpm':{ + 'requires':'python, python-setuptools, pyside-mobility, python-pyside,python-qt4-core, python-qt4-maemo5, python-oauth2, python-simplejson, python-conic, python-imaging', + 'conflicts':'khweeteur-experimental', + 'icon':'khweeteur.png', + 'group':'Network',}} + ) +