1+ #!/usr/bin/env python3
2+ """
3+ Surveille un répertoire source (récursivement) et copie tout nouveau fichier
4+ créé dans un répertoire destination (à plat, sans préserver la structure).
5+
6+ Les répertoires source/destination peuvent être fournis en ligne de commande,
7+ sinon ils sont lus dans config.json (à côté de ce script).
8+
9+ Usage:
10+ python watch_and_copy.py run [<source_dir> <dest_dir>] # foreground
11+ python watch_and_copy.py start [<source_dir> <dest_dir>] # daemon
12+ python watch_and_copy.py stop
13+ python watch_and_copy.py status
14+ python watch_and_copy.py restart [<source_dir> <dest_dir>] # daemon
15+ """
16+
17+ import os
18+ import sys
19+ import json
20+ import shutil
21+ import signal
22+ import logging
23+ from pathlib import Path
24+ from watchdog .observers import Observer
25+ from watchdog .events import FileSystemEventHandler
26+
27+ PID_FILE = Path ("/tmp/watch_and_copy.pid" )
28+ LOG_FILE = Path ("/tmp/watch_and_copy.log" )
29+ CONFIG_FILE = Path ("/etc/watch_and_copy/config.json" )
30+
31+
32+ def load_config () -> dict :
33+ if not CONFIG_FILE .exists ():
34+ return {}
35+ with open (CONFIG_FILE , "r" , encoding = "utf-8" ) as f :
36+ return json .load (f )
37+
38+
39+ def setup_logging (foreground : bool = False ):
40+ logging .basicConfig (
41+ filename = None if foreground else LOG_FILE ,
42+ level = logging .INFO ,
43+ format = "%(asctime)s %(message)s" ,
44+ datefmt = "%Y-%m-%d %H:%M:%S" ,
45+ )
46+
47+
48+ class CopyOnCreate (FileSystemEventHandler ):
49+ def __init__ (self , dest : Path ):
50+ self .dest = dest
51+
52+ def on_created (self , event ):
53+ if event .is_directory :
54+ return
55+ src = Path (event .src_path if isinstance (event .src_path , str ) else event .src_path .decode ())
56+ dst = self .dest / src .name
57+ shutil .copy2 (src , dst )
58+ logging .info (f"Copié { src } → { dst } " )
59+
60+
61+ def daemonize ():
62+ # Double fork pour détacher du terminal
63+ if os .fork () > 0 :
64+ sys .exit (0 )
65+ os .setsid ()
66+ if os .fork () > 0 :
67+ sys .exit (0 )
68+
69+ # Redirige stdin/stdout/stderr vers /dev/null
70+ devnull = os .open (os .devnull , os .O_RDWR )
71+ for fd in (0 , 1 , 2 ):
72+ os .dup2 (devnull , fd )
73+ os .close (devnull )
74+
75+ PID_FILE .write_text (str (os .getpid ()))
76+
77+
78+ def get_pid () -> int | None :
79+ if PID_FILE .exists ():
80+ try :
81+ return int (PID_FILE .read_text ().strip ())
82+ except ValueError :
83+ return None
84+ return None
85+
86+
87+ def do_run (source : Path , dest : Path ):
88+ """Lance la surveillance en foreground (Ctrl+C pour arrêter)."""
89+ if not source .is_dir ():
90+ print (f"Erreur : répertoire source introuvable : { source } " )
91+ sys .exit (1 )
92+
93+ dest .mkdir (parents = True , exist_ok = True )
94+ setup_logging (foreground = True )
95+
96+ logging .info (f"Surveillance de : { source } " )
97+ logging .info (f"Destination : { dest } " )
98+ logging .info ("Ctrl+C pour arrêter." )
99+
100+ observer = Observer ()
101+ observer .schedule (CopyOnCreate (dest ), str (source ), recursive = True )
102+ observer .start ()
103+
104+ try :
105+ observer .join ()
106+ except KeyboardInterrupt :
107+ observer .stop ()
108+ observer .join ()
109+ logging .info ("Arrêté." )
110+
111+
112+ def do_start (source : Path , dest : Path ):
113+ if get_pid () is not None :
114+ print ("Le daemon tourne déjà. Utilisez 'restart' pour le relancer." )
115+ sys .exit (1 )
116+
117+ if not source .is_dir ():
118+ print (f"Erreur : répertoire source introuvable : { source } " )
119+ sys .exit (1 )
120+
121+ dest .mkdir (parents = True , exist_ok = True )
122+
123+ print (f"Démarrage du daemon…" )
124+ print (f" Source : { source } " )
125+ print (f" Destination : { dest } " )
126+ print (f" Logs : { LOG_FILE } " )
127+ print (f" PID : { PID_FILE } " )
128+
129+ daemonize ()
130+ setup_logging ()
131+
132+ logging .info (f"Daemon démarré (PID { os .getpid ()} )" )
133+ logging .info (f"Surveillance de : { source } " )
134+ logging .info (f"Destination : { dest } " )
135+
136+ observer = Observer ()
137+ observer .schedule (CopyOnCreate (dest ), str (source ), recursive = True )
138+ observer .start ()
139+
140+ def on_signal (signum , frame ):
141+ observer .stop ()
142+
143+ signal .signal (signal .SIGTERM , on_signal )
144+ signal .signal (signal .SIGINT , on_signal )
145+
146+ observer .join ()
147+ PID_FILE .unlink (missing_ok = True )
148+ logging .info ("Daemon arrêté." )
149+
150+
151+ def do_stop ():
152+ pid = get_pid ()
153+ if pid is None :
154+ print ("Le daemon n'est pas en cours d'exécution." )
155+ sys .exit (1 )
156+ try :
157+ os .kill (pid , signal .SIGTERM )
158+ PID_FILE .unlink (missing_ok = True )
159+ print (f"Daemon arrêté (PID { pid } )." )
160+ except ProcessLookupError :
161+ print (f"Processus { pid } introuvable — nettoyage du fichier PID." )
162+ PID_FILE .unlink (missing_ok = True )
163+
164+
165+ def do_status ():
166+ pid = get_pid ()
167+ if pid is None :
168+ print ("Daemon : arrêté" )
169+ return
170+ try :
171+ os .kill (pid , 0 )
172+ print (f"Daemon : en cours d'exécution (PID { pid } )" )
173+ print (f"Logs : { LOG_FILE } " )
174+ except ProcessLookupError :
175+ print (f"Daemon : arrêté (fichier PID périmé)" )
176+ PID_FILE .unlink (missing_ok = True )
177+
178+
179+ USAGE = (
180+ f"Usage: { sys .argv [0 ]} run [<source> <dest>] # foreground\n "
181+ f" { sys .argv [0 ]} start [<source> <dest>] # daemon\n "
182+ f" { sys .argv [0 ]} stop\n "
183+ f" { sys .argv [0 ]} status\n "
184+ f" { sys .argv [0 ]} restart [<source> <dest>] # daemon\n "
185+ f"\n "
186+ f"Si <source>/<dest> sont omis, ils sont lus dans { CONFIG_FILE } ."
187+ )
188+
189+
190+ def main ():
191+ if len (sys .argv ) < 2 :
192+ print (USAGE )
193+ sys .exit (1 )
194+
195+ cmd = sys .argv [1 ]
196+
197+ if cmd == "stop" :
198+ do_stop ()
199+ elif cmd == "status" :
200+ do_status ()
201+ elif cmd in ("run" , "start" , "restart" ):
202+ if len (sys .argv ) == 4 :
203+ source_raw , dest_raw = sys .argv [2 ], sys .argv [3 ]
204+ elif len (sys .argv ) == 2 :
205+ config = load_config ()
206+ source_raw , dest_raw = config .get ("source_dir" ), config .get ("dest_dir" )
207+ if not source_raw or not dest_raw :
208+ print (f"Erreur : source_dir/dest_dir manquants dans { CONFIG_FILE } " )
209+ sys .exit (1 )
210+ else :
211+ print (USAGE )
212+ sys .exit (1 )
213+
214+ source = Path (source_raw ).resolve ()
215+ dest = Path (dest_raw ).resolve ()
216+ if cmd == "run" :
217+ do_run (source , dest )
218+ else :
219+ if cmd == "restart" :
220+ do_stop ()
221+ do_start (source , dest )
222+ else :
223+ print (USAGE )
224+ sys .exit (1 )
225+
226+
227+ if __name__ == "__main__" :
228+ main ()
0 commit comments