# -*- Mode: Python; tab-width: 4 -*-
#	Author: Sam Rushing <rushing@nightmare.com>
#	$Id: ftp.py,v 1.6 1997/10/20 17:22:01 rushing Exp $

# Why do all existing ftp clients send port commands before
# a data command?  the rfc implies that this is unnecessary.
# is it a security issue? Or a 'many unixes are broken' issue?
# [the 'default' data port is privileged, so a user
#  client would not be able to listen on it.  that's why the
#  port command is used at all]

import consumer
status_report = consumer.status_report

# We build on the infrastructure provided by the <consumer> module.
# Neither of <consumer>'s single and multi-line command handlers
# can stomach a multi-line ftp response.  A new consumer object
# is built, and we use typical_internet_client.send_command directly,
# instead of through the usual 'send_single_line_command' interface.

# This same consumer should work for SMTP, the only other protocol I
# know of that uses multi-line replies.  [are there any others?]

import regex
# non-final lines follow the numeric code with a '-'.
final_response_line = regex.compile ('\([0-9][0-9][0-9]\) .*')

# the default multi-line response line-handling function ignores them.
def toss (line):
	pass

import string

import socket

# maybe this ought to be passed in at create time?
# no, it needs to be computed from the socket.
#IP_ADDRESS = socket.gethostbyname (socket.gethostname())

class multi_line_reply_consumer:
	def __init__ (self, done_fun, line_fun=toss):
		self.terminator = '\r\n'
		self.line_fun = line_fun
		self.done_fun = done_fun
		self.result = ''

	def collect (self, data):
		self.result = self.result + data

	def found_terminator (self):
		if final_response_line.match (self.result) == len (self.result):
			self.done_fun (self.result)
			self.result = ''
			return 1
		else:
			# the user is not interested in FTP codes
			self.line_fun (self.result[4:])
			self.result = ''
			return 0

	def abort (self, why):
		self.done_fun (why)

# variations:
# a) create the data channel immediately after the connect
# b) optional single or iterated data channel [why???]
# c) PASV mode, of course

# need to watch for the '421 Timeout' message from the server.
# might want to subclass typical_internet_client for this purpose.

class ftp_client:
	def __init__ (self, host, user, passwd, port=21, pasv=0):
		self.host	= host
		self.user	= user
		self.passwd	= passwd
		self.port	= port
		self.client	= None
		self.data_channel = None
		self.pasv = 0
	
	# for commands that don't use the data channel
	def command (self, command, done_fun, line_fun=toss):
		# build a new consumer object
		con = multi_line_reply_consumer (
			done_fun,
			line_fun
			)
		# push the command
		if command is not None:
			self.client.send_command (command, con)
		else:
			self.client.push_consumer (con)

	def close (self):
		if self.data_channel:
			self.data_channel.close()
		self.client.close()

	# --------------------------------------------------
	# connection sequence
	# we 'can' everything here for the user... giving the
	# appearance that connecting and logging in are a single
	# action
	# --------------------------------------------------

	def connect (self, connect_fun, line_fun=toss):
		self.client = consumer.typical_internet_client()
		con = consumer.function_chain (
			self.st_initial_connect,
			self.st_user,
			self.st_pass,
			connect_fun
			)
		con.line_fun = line_fun
		self.command (
			None,
			con,
			line_fun
			)
		self.client.go (self.host, self.port)
		
	def st_initial_connect (self, continuation, data):
		if data[:3] != '220':
			continuation (status_report (0, 'unexpected response "%s"' % data))
		else:
			self.command (
				'USER %s' % self.user,
				continuation,
				continuation.line_fun
				)

	def st_user (self, continuation, data):
		if data[:3] != '331':
			continuation (status_report (0, 'unexpected response "%s"' % data))
		else:
			self.command (
				'PASS %s' % self.passwd,
				continuation,
				continuation.line_fun
				)

	def st_pass (self, continuation, data):
		if data[:3] != '230':
			continuation (status_report (0, 'unexpected response "%s"' % data))
		else:
			self.new_port (continuation)

	# --------------------------------------------------
	# creating local data port
	# --------------------------------------------------

	def new_port (self, continuation):
		if self.data_channel:
			self.data_channel.close()
		self.data_channel = data_listen_channel (self)
		self.command (
			'PORT %s' % self.data_channel.port_string(),
			consumer.function_chain (
				self.st_port,
				continuation
				)
			)

	# reponse to the port command
	def st_port (self, continuation, data):
		if data[:3] != '200':
			continuation.abort (
				status_report (
					0, 'unexpected reponse to PORT command "%s"' % data
					)
				)
		else:
			continuation (status_report (1, data))

	# ==================================================
	# For each data command, we need to request a new data connection.
	#

	def data_request_command (self, *args):
		self.new_port (
			consumer.delayed_function (
				self.data_request_command_thunk,
				args
				)
			)

	def data_request_command_thunk (self,
									cmd,
									cmd_done_fun,
									data_callback,
									data_done_fun):
		self.data_channel.push_receive_funs (data_callback, data_done_fun)
		self.command (
			cmd, 
			consumer.function_chain (
				self.st_data_cmd,
				self.st_data_done,
				cmd_done_fun
				)
			)

	# response the user command
	def st_data_cmd (self, continuation, data):
		if data[:3] != '150':
			continuation.abort (
				status_report (
					0, 'unexpected response "%s"' % data
					)
				)
		else:
			# push_consumer is used for 'unsolicited' responses,
			# in this case there are actually two responses to
			# the user command - one for opening the connection,
			# and one for closing it.
			self.client.push_consumer (
				consumer.simple_consumer (continuation)
				)

	def st_data_done (self, continuation, data):
		if data[:3] != '226':
			continuation.abort (
				status_report (
					0, 'unexpected response "%s"' % data
					)
				)
		else:
			# finally, the end of the chain!
			continuation (status_report (1, data))

	# ==================================================
	# Protocol-Level Commands
	# ==================================================	

	def nlst (self, arg, cmd_done_fun, data_callback, done_fun):
		if not arg:
			cmd = 'NLST'
		else:
			cmd = 'NLST %s' % arg
		self.data_request_command (
			cmd, cmd_done_fun, data_callback, done_fun
			)

	# ==================================================
	# User-Level Commands
	# ==================================================	

	# --------------------------------------------------
	# CWD (JeffBauer@Bigfoot.com borrowed from ftplib.py)
	# --------------------------------------------------
	def cwd (self, dirname, done_fun):
		if dirname == '..':
			do_cwd = 'CDUP'
		else:
			do_cwd = 'CWD %s' % dirname
		self.command (
			do_cwd,
			consumer.function_chain (
					self.st_cwd,
					done_fun
					)
			)

	def st_cwd (self, continuation, data):
		if data[:3] != '250':
			continuation (
				status_report (
					0,
					'unexpected response to CWD command' % data
					)
				)
		else:
			continuation (1)


	# --------------------------------------------------
	# GET
	# --------------------------------------------------

	def get (self, remote, local, done_fun, receiver_class=None):
		if receiver_class == None:
			receiver_class = local_file_receiver
		try:
			receiver = receiver_class (self, local, done_fun)
		except:
			done_fun (
				status_report (
					0, "couldn't open local file %s" % local
					)
				)
			return
		self.data_request_command (
			'RETR %s' % remote,
			done_fun,
			receiver.data_callback,
			receiver.done_fun
			)
		
	# --------------------------------------------------
	# QUIT
	# --------------------------------------------------

	def quit (self, done_fun):
		self.command (
			'QUIT',
			consumer.function_chain (
				self.st_quit,
				done_fun
				)
			)

	def st_quit (self, continuation, data):
		if data[:3] != '221':
			continuation (
				status_report (
					0,
					'unexpected response to QUIT command' % data
					)
				)
		else:
			self.close()
			continuation (1, data)

	# --------------------------------------------------
	# TYPE
	# --------------------------------------------------
	def type (self, type, done_fun):
		self.command (
			'TYPE %s' % type,
			consumer.function_chain (
				self.st_type,
				done_fun
				)
			)

	def st_type (self, continuation, data):
		if data[:3] != '200':
			continuation (
				status_report (
					0,
					'unexpected response to TYPE command' % data
					)
				)
		else:
			continuation (1)

# status:
# need a way to spec 'line_fun' for the whole connect sequence,
# maybe just store it as a member.  Either they want them all,
# or none of them.

import asyncore
import asynchat

class receive_channel (asyncore.dispatcher):
	# 64k default buffer size
	default_buffer_size = 65536

	def __init__ (self,
				  conn,
				  client,
				  # data_callback (data)
				  data_callback=None,
				  # done_fun (status)
				  done_fun,
				  buffer_size=None):
		self.client			= client
		self.data_callback	= data_callback
		self.done_fun		= done_fun
		if buffer_size is None:
			self.buffer_size = self.default_buffer_size
		else:
			self.buffer_size = buffer_size
		asyncore.dispatcher.__init__ (self, conn)
		
	def handle_connect (self):
		pass

	def log (self, args):
		pass

	def handle_read (self):
		data = self.recv (self.buffer_size)
		if data:
			self.data_callback (data)

	def handle_close (self):
		self.close()
		self.done_fun (1)

class data_listen_channel (asyncore.dispatcher):
	def __init__ (self, client, ip_address=None):
		if not ip_address:
			#self.ip_address = socket.gethostbyaddr (IP_ADDRESS)[0]
			# use the client's sockname to get a valid IP address.
			#print 'client=%s' % client
			self.ip_address = client.client.getsockname()[0]
		else:
			self.ip_address = ip_address
		asyncore.dispatcher.__init__ (self)
		self.client = client
		self.create_socket (socket.AF_INET, socket.SOCK_STREAM)
		self.bind ((self.ip_address, 0))
		self.addr = self.socket.getsockname()
		# fifo so we can support more than one request!
		self.fifo = asynchat.fifo()
		self.listen(10)

	def port_string (self):
		port = self.addr[1]
		return string.joinfields (
			string.splitfields (self.ip_address, '.') + [str(port/256),str(port%256)],
			','
			)

	def push_receive_funs (self, data_callback, data_done_fun):
		self.fifo.push (('RECEIVE', data_callback, data_done_fun))

	def handle_connect (self):
		pass

	def log (self, *args):
		pass

	def handle_accept (self):
		conn, addr = self.accept()
		#not necessarily!
		#self.close()
		#print 'incoming connection...', conn, addr
		flag, info = self.fifo.pop()
		if flag:
			if info[0] == 'RECEIVE':
				receive_channel (conn, self.client, info[1], info[2])
			else:
				pass
		else:
			self.log ('unexpected incoming connection from %s' % repr(addr))
			conn.close()

import time

class local_file_receiver:
	def __init__ (self, server, filename, user_done_fun, mode='b'):
		self.server = server
		self.filename = filename
		self.user_done_fun = user_done_fun
		self.fd = open (filename, 'w'+mode)
		self.start = time.time()
		self.bytes = 0
		
	def data_callback (self, data):
		self.bytes = self.bytes + len(data)
		self.fd.write (data)

	def done_fun (self, status):
		if status:
			total = time.time() - self.start
			xfr = float(self.bytes) / total
			print 'received %d bytes in %.2f seconds (%.2f bytes/sec)' % (
				self.bytes,
				total,
				xfr
				)
		else:
			self.user_done_fun (status)
